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

Dernière mise à jour le July 17, 2026
lxml, passé au crible : le moteur XPath qui surclasse encore tous les parseurs Python
Résumé IA
Cette revue de lxml présente la bibliothèque comme le binding Python historique autour de libxml2 et libxslt, avec un avantage majeur que les parseurs plus récents égalent rarement : un vrai moteur XPath. L’article teste la couverture XPath, les modes de rigidité du parseur, le comportement mémoire du mode streaming, l’écart d’expressivité entre CSS et XPath, ainsi que les limites de profondeur de libxml2. Il montre lxml comme rapide, économe en mémoire et particulièrement puissant pour les charges XML et HTML qui ont besoin d’axes, de prédicats, de fonctions, de streaming ou d’un mode de récupération robuste. La revue explique aussi les limites de profondeur pensées pour la sécurité et le rôle de huge_tree pour en repousser la frontière.

Tous les quelques mois, un parseur HTML plus rapide débarque, les benchmark tournent partout, et quelqu’un décrète que les vieux outils sont has-been. Puis vous devez sélectionner chaque paragraphe qui contient un mot précis, ou récupérer le parent d’un nœud trouvé, et là vous vous rappelez pourquoi lxml est toujours ouvert dans l’autre onglet.

lxml est un binding Python vieux de 20 ans autour de libxml2. Ce n’est ni tout neuf, ni spécialement sexy. Et pourtant, pour une tâche bien précise — tout ce qui demande un vrai XPath — aucun autre outil grand public en Python ne tient vraiment la comparaison. Voici un retour terrain sur ce qu’il fait, ses petites victoires discrètes, et les pièges de ses réglages par défaut si vous ne les connaissez pas.

lxml en une phrase : ce que c’est vraiment

lxml est un binding Python pour les bibliothèques C libxml2 et libxslt. Ce n’est ni un scraper ni un navigateur : c’est un parseur et un sérialiseur. Il transforme du markup en arbre interrogeable et modifiable, puis reconvertit cet arbre en octets. Il fournit une API compatible ElementTree, un moteur complet XPath 1.0, XSLT 1.0 et la validation de schémas, le tout maintenu par Stefan Behnel sous la bannière « la bibliothèque la plus riche en fonctionnalités et la plus simple à utiliser 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
Problèmes ouverts16
LicenceBSD-3-Clause
Créé le2011-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 que quelqu’un ne m’accuse d’en faire trop : il n’y a pas de secret dans cette revue. lxml est assez ancien pour que chaque comportement évoqué ici soit documenté quelque part dans la documentation lxml, dans un changelog de libxml2 ou dans une discussion Launchpad. Je n’ai découvert aucune astuce exclusive et non documentée, et je n’en inventerai pas. L’intérêt de ce qui suit, c’est que tout est structuré, mesuré et organisé autour de lxml en tant qu’objet d’étude — pas parce que ce serait une découverte.

Le protocole de test (et pourquoi certains chiffres de temps sont empruntés)

Deux catégories 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, espaces de noms, encodage, cycle de vie des nœuds — je les ai exécutés à neuf sur une machine : macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Chaque chiffre présent dans les fichiers artifacts/raw/*.json est calculé par un script lancé pour l’occasion, pas tapé à la main. Les tests de capacité sont déterministes (booléens et enums), donc un seul passage suffit : la charge machine ne change pas le fait que //a/@href renvoie une chaîne d’attribut.

Les chiffres de temps et d’empreinte mémoire ne viennent pas de cette série. 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 voulu. Relancer des benchmarks de timing en même temps qu’un lot de scripts de capacité introduit une contention CPU qui brouillerait les chiffres repris, et ce serait du travail en double : dans ce pack, lxml avait déjà le rôle de bibliothèque témoin entièrement mesurée. Réutiliser les mêmes mesures permet de comparer à périmètre identique, au lieu d’ajouter une seconde mesure légèrement différente. Donc quand vous voyez une valeur en millisecondes ci-dessous, lisez-la comme « même banc de test, état au 2026-07-13 », pas comme « je l’ai retimée aujourd’hui ».

Chaque constat porte une étiquette de confiance : single-observation pour les tests déterministes, triple-run pour les distributions de timing réutilisées, hypothesis quand j’avance un mécanisme sans l’avoir isolé.

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

C’est le point fort, donc commençons par là.

lxml XPath coverage moat with axes predicates and functions

J’ai soumis le xpath() de lxml à une matrice de 37 cas, préenregistrée — le résultat attendu de chaque cas était écrit dans le code avant l’exécution, impossible donc de noter avec indulgence. Dix axes, neuf styles de prédicats, dix fonctions natives, trois types de retour scalaires, et cinq cas-pièges intentionnels 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 réussis
Prédicats[1] / last() / position()<n / égalité d'attribut / existence d'attribut / and / or / [.//a] imbriqué / not()9/9 réussis
Fonctionstext() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string()10/10 réussies
Types de retourbooléen / scalaire numérique3/3 réussis
Cas-piègesmatches() / séquences / if-then-else / except / erreur de syntaxe5/5 correctement rejetés

Le score est de 37/37, et la colonne des pièges est la plus importante. matches(), les expressions de séquences, if/then/else et except relèvent tous de XPath 2.0, et le moteur 1.0 de libxml2 ne les « supporte » pas à moitié : il lève XPathEvalError et refuse la requête, au lieu de retourner silencieusement un mauvais ensemble de nœuds. C’est donc un score parfait après tentative de casse, pas un score gonflé avec des cas faciles. Ici, tout correspond exactement à ce que décrivent les docs XPath de lxml — et c’est bien le but.

J’admets un faux pas du banc de test, parce que c’est la version du « 37/37 » en laquelle on peut avoir confiance. Mon premier ensemble attendu pour //div[.//a[@href]] prévoyait deux résultats ; l’exécution en a renvoyé un seul. J’ai cru pendant une trentaine de secondes que lxml se trompait, puis j’ai revérifié le fixture et constaté que le deuxième élément était un <footer>, pas un <div> — l’erreur venait de mon attente, pas du moteur. J’ai corrigé le résultat attendu et laissé la faute dans un commentaire source. C’est le bon ordre des soupçons : d’abord votre test, ensuite la vieille bibliothèque C de 20 ans.

XPath vs CSS : ce qu’on ne peut littéralement pas exprimer en CSS

L’affirmation abstraite « XPath est plus puissant » mérite un chiffre concret, alors j’ai quantifié l’écart. lxml expose à la fois .xpath() et .cssselect() (ce dernier traduit le CSS en XPath sous le capot). J’ai pris dix objectifs de sélection et vérifié lesquels le 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 depuis l’enfant (//b/parent::p)OuiPas de sélecteur parent
Renvoyer une valeur d’attribut (//a/@href)OuiUniquement des éléments
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 de texte (string-length(text())>5)OuiPas de prédicat de longueur
nth-child / last-child / frère adjacentOuiOui (3 cas de base)

Sept des dix objectifs n’ont aucun équivalent CSS. Filtrage sur le contenu textuel, navigation vers les parents et ancêtres, récupération d’une valeur d’attribut ou d’un simple nœud texte, prédicats fondés sur un comptage : CSS ne sait rien faire de tout cela. Seuls trois cas (nth-child, last-child, frère adjacent) fonctionnent dans les deux mondes. Voilà la réponse chiffrée à « qu’est-ce que je gagne concrètement avec lxml ? ». selectolax est limité au CSS et n’a pas du tout de méthode xpath(), donc ces sept types de requêtes deviennent là-bas des boucles Python en plusieurs étapes… ou n’existent tout simplement pas. Si votre logique de scraping repose sur l’un d’eux, la décision est prise.

(Et oui, le banc m’a encore repris 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 tolérance : etree, recover et lxml.html

XPath est la raison de choisir lxml. Les trois modes de tolérance sont la raison de le garder.

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

La plupart des parseurs vous offrent un seul comportement face à une entrée cassée. lxml en propose trois, et ils sont assez prévisibles pour que j’aie envoyé six familles de balisage malformé à chacun, avec un résultat attendu défini à l’avance pour chaque chemin.

Entrée malforméelxml.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
Plusieurs racines <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

Les sept cas sur sept correspondent à l’attendu préenregistré. lxml.etree lève XMLSyntaxError sur les six familles de malformations. Ajoutez recover=True au même parseur, et il avale les erreurs pour reconstruire un arbre exploitable — et c’est la partie sous-estimée — parser.error_log énumère ensuite toutes les erreurs qu’il a absorbées. lxml.html accepte tout sans broncher.

Le classificateur qui décide « levée / récupération / acceptation » s’appuie lui-même sur la longueur réelle de error_log, pas sur une valeur codée en dur ; c’est pourquoi un document bien formé analysé avec recover=True est correctement classé « accepte » (journal vide) plutôt que « récupère ». Ma première version du classificateur marquait toute sortie recover=True comme « récupère » et mal classait l’entrée propre ; lire le vrai error_log a corrigé cela.

Concrètement, cela permet : validation stricte quand un flux cassé doit échouer bruyamment, lxml.etree ; HTML réel et sale qu’il faut simplement arriver à lire, lxml.html ; et le cas intermédiaire que la plupart des outils ne gèrent pas — « sois tolérant, mais dis-moi exactement ce qui est cassé pour que je le journalise » — recover=True avec lecture du journal d’erreurs. selectolax dispose du mode tolérant, et de 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 de vitesse. selectolax n’ingère qu’une chaîne complète : pas d’interface incrémentale. iterparse de lxml itère au moment de la fermeture des éléments, et associé au patron classique fast_iter (appel à elem.clear() et suppression des frères précédents au fil de l’eau), il garde une mémoire plate quelle que soit la taille du document.

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

J’ai mesuré cet effet directement — pic de RSS via ru_maxrss, chaque sujet dans un nouveau processus, sur 300 000 éléments <record> totalisant environ 15 MB.

ModeDelta RSS de picNotes
iterparse + clear (fast_iter)~1-2 MBlibération au fil de l’eau ; plat quel que soit le volume
iterparse sans clear~386 MBconserve les références ; aussi lourd qu’un chargement complet
etree.parse (chargement complet, référence)~386 MBlourd comme attendu ; montre que le compteur voit bien l’ordre de grandeur

Le mode borné maintient le delta RSS de pic autour de 1-2 MB contre ~386 MB pour un chargement complet — soit un écart d’environ 0,3 à 0,4 % en ordre de grandeur — et le premier événement de record survient avant même la fin de la lecture du fichier, donc c’est bien incrémental, pas du faux streaming. La ligne instructive est celle du milieu. Lancez la même boucle iterparse mais sans clear(), et la mémoire remonte à ~386 MB, parce que vous gardez des références sur tout. Le gain vient de clear(), pas de iterparse seul. La valeur de référence en chargement complet, très au-dessus du mode borné, confirme aussi que le compteur RSS perçoit réellement l’écart d’ordre de grandeur, au lieu de lire à l’aveugle. (Ce test mémoire a bien été exécuté dans ce pack — il s’agit d’une mesure d’empreinte, distincte des timings empruntés.)

En pratique : un export XML de plusieurs gigaoctets qui ne tient pas en RAM n’a tout simplement pas d’équivalent chez 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 de namespaces, couvrant RSS sur trois espaces de noms, 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 sous la forme exacte ["Alice", "Bob"], résout //atom:link/@href et //content:encoded à travers trois espaces de noms distincts dans un même document, gère //s:rect et //s:use/@xlink:href dans le second namespace de SVG, découpe les noms en notation Clark {uri}local avec QName, et expose les mappings via nsmap. C’est un comportement documenté et maintenu, et c’est une dimension entière que selectolax ne couvre pas, parce que selectolax est limité à HTML5 et ne traite pas les namespaces XML arbitraires.

Il existe toutefois un piège documenté qu’il vaut la peine de mémoriser. XPath ne connaît pas le concept d’espace de noms par défaut. Visez //book dans un document qui déclare xmlns="urn:...", et vous obtiendrez zéro résultat — le préfixe vide n’existe pas en XPath, comme le rappelle la documentation lxml. Il faut soit lier un préfixe artificiel (//c:book avec namespaces={"c": "urn:..."}, ce qui a trouvé les trois éléments), 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éellement sales : fidélité sur 11 scrapes réels

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

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

Les onze pages ont toutes été analysées avec lxml.html, et les nombres de liens, de titres et d’images correspondent aux comptes lxml réutilisés depuis le pack selectolax sur les onze cas — vérification croisée : true. C’est cet accord qui me permet de dire que la réutilisation se compare bien à périmètre égal, et qu’il ne s’agit pas de deux mesures différentes sous la même étiquette.

L’autre constat : le parseur XML strict a levé une erreur sur dix des onze pages. Les vraies pages du web ne sont presque jamais des XML bien formés, ce qui explique précisément l’existence du mode de récupération HTML de libxml2 pour les avaler. La seule exception était BBC News, rendue via Next.js et suffisamment bien formée pour survivre au parsing XML strict. Tout ce qui s’appelle « HTML » n’a pas besoin du mode récupération.

Une précision de comptage 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 truthy) en a compté 341. Les deux écarts supplémentaires sont des liens avec href="" vide. C’est une différence de convention de comptage — attribut existant contre attribut non vide — pas une différence de comportement de lxml, et les chiffres se réconcilient dès qu’on aligne le prédicat. Bon à savoir en scraping : savoir si les href vides comptent 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 observé que lxml perdait le contenu le plus profond sur des balisages <div> imbriqués à 1 000 et 5 000 niveaux, et formulait cela comme « lxml perd silencieusement le contenu le plus profond ». Je voulais le mécanisme, alors j’ai testé le parseur par défaut face à 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 (perte encore)

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 la discussion Launchpad lxml sur XML_PARSE_HUGE. Passez huge_tree=True, et les profondeurs 300 et 1 000 reviennent entièrement. En revanche, à 5 000 niveaux, on n’atteint encore que 2 045 même avec huge_tree activé : il existe un second plafond de récursion libxml2, plus dur, au-dessus de la limite configurable, et huge_tree ne le supprime pas.

L’action à en tirer est donc concrète : 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 au-delà de 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 deuxième plafond que cette correction n’atteint pas.

DOM 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 en conservant leur texte), strip_elements (supprime les balises et leur texte), drop_tree (spécifique à lxml.html), et le modèle à double emplacement texte/queue 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 passé cinq tests sur cinq : tostring en mode XML et HTML (le HTML ne ferme correctement pas les éléments vides de manière auto-fermante), pretty_print, la canonicalisation C14N (method="c14n", autre exclusivité lxml) et un aller-retour propre.

L’encodage est l’un des points 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 récupère 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 altérée par les deux autres moteurs (Lexbor produisait des caractères de remplacement, Modest supprimait des octets). La détection de charset via libxml2 est simplement plus stable ici.

L’inverse, c’est la rigidité sur la manière de déclarer un encodage. encoding="latin-1" dans une déclaration XML lève XMLSyntaxError: Unsupported encoding: latin-1, alors que la forme IANA canonique encoding="ISO-8859-1" se parse correctement et renvoie café. libxml2 n’accepte que les noms d’encodage canoniques, pas leurs alias — un détail documenté dès Launchpad #613302. C’est agaçant si on l’ignore, trivial une fois connu.

Enfin, le cycle de vie des nœuds. J’ai exécuté trois scénarios de poignée obsolète dans des sous-processus isolés (un crash franc se verrait par un code de sortie non nul) : conserver un nœud après collecte du garbage collector de son arbre, lire une poignée après drop_tree(), et réutiliser un nœud après remove(). Aucun segfault dans les trois cas : lxml conserve la référence d’un nœud vers son arbre pour éviter les use-after-free. Même constat de santé pour selectolax sur ce test.

Vitesse et mémoire (empruntées, mais assumées)

Tout ce qui suit est réutilisé depuis le pack selectolax, état 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 de vous laisser croire le contraire.

DimensionValeur lxmlLecture
Pur parsing p50 (10 MB)77,9 ms~33-34 % plus rapide que selectolax-Lexbor
Parsing complet + extraction p50 (1 MB / 10 MB)14,18 ms / 172,9 msà peu près au niveau de Lexbor sur les petits volumes
Débit CSS sur 100k nœuds3 002 646 nœuds/sdans la tranche la plus rapide des trois moteurs C
Delta RSS sur 10 MB128,9 MBle plus sobre des six parseurs, ~1,7× plus économe que BeautifulSoup
Démarrage à froid à l’import14,1 ms~2,3× plus rapide que des imports de type parsel

Les chiffres de pure parse et de débit sont solides, et lxml est le plus économe en mémoire parmi les six parseurs mesurés. La question du threading mérite toutefois une réserve. Les données réutilisées montrent un gain wall-clock de seulement 1,21× avec 4 threads, marqué comme non concluant — mais cela correspond au chemin utilisant un parseur partagé par défaut. La FAQ lxml est explicite : le GIL n’est relâché pendant le parsing que lorsque 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é la surface d’API permettant de faire cela correctement (XMLParser.copy() existe, get/set_default_parser existent, XPathEvaluator possède un verrou interne), mais je n’ai pas mesuré le gain avec un parseur par thread — ce serait une nouvelle mesure de timing, et ce pack n’en produit pas. Lisez donc « 1,21× » comme « sur le chemin naïf avec parseur partagé », pas comme la limite du threading dans lxml.

Et une astérisque sur l’ensemble : ces chiffres sont mono-plateforme, macOS arm64. L’affirmation selon laquelle le parsing pur d’lxml bat Lexbor va à l’encontre du consensus habituel, selon lequel le moteur Lexbor serait le plus rapide ; elle mérite donc un recontrôle sur Linux x86_64 avant que quiconque la considère comme définitive.

Licence : la victoire ennuyeuse

lxml est publié sous BSD-3-Clause, et les bibliothèques C qu’il embarque — libxml2 et libxslt — sont toutes deux sous MIT. Toute la chaîne est donc très permissive, sans copyleft nulle part, ce qui compte dès qu’il faut redistribuer. À titre de contraste, le wheel selectolax embarque Modest en LGPL-2.1 et Lexbor en Apache-2.0 ; lxml offre donc un scénario plus simple pour un produit fermé.

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

Où lxml se place — et où une couche d’extraction IA prend le relais

Il faut clarifier la frontière, car l’erreur de catégorie est fréquente. lxml est une bibliothèque de parsing. Elle vous donne un arbre et un excellent moteur de requêtes ; tout le reste autour de cet arbre reste votre affaire : récupérer la page, rendre le JavaScript, contourner l’anti-bot, écrire et maintenir les XPath, puis structurer le résultat. C’est une couche différente d’un service d’extraction hébergé, et les deux ne sont pas tant concurrents que voisins.

Pour un développeur qui ne veut pas porter la pile fetch-render-select-maintain, cette couche supérieure ressemble à ce que propose Thunderbit — et pour ce public, il s’agit de l’API, du serveur MCP et du CLI, pas de l’extension navigateur. La Thunderbit Open API expose POST /distill pour transformer une page en Markdown propre et POST /extract pour extraire des données structurées à partir d’un JSON Schema, avec un sélecteur renderMode et des jobs par lot pour les gros volumes. Le même moteur existe aussi en serveur MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) pour les agents et assistants de code, ainsi qu’en CLI exécutable directement depuis un terminal via npx @thunderbit/thunderbit-cli. Il gère le rendu JavaScript, l’anti-bot et les CAPTCHA nativement, et renvoie du JSON conforme au schéma — autrement dit, la couche au-dessus du parsing, pas son remplaçant.

Essayer Thunderbit pour l’extraction de données web

Le positionnement est simple. Choisissez lxml quand vous maîtrisez le pipeline et que vous voulez un contrôle XPath chirurgical sur un arbre que vous comprenez. Choisissez une API d’extraction IA quand vous préférez ne pas maintenir du tout les sélecteurs et 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 ; voici donc 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 du pack d’origine — le résultat selon lequel « lxml est plus rapide en parsing pur » va à l’encontre du consensus habituel et mérite un recontrôle sous Linux x86_64. Le gain du threading avec un parseur par thread n’a pas été mesuré (il faudrait un nouveau timing). J’ai mesuré la mémoire d’iterparse sur 300k enregistrements, mais pas sur des XML réels de taille gigaoctet, ni iterparse sur HTML versus XML, ni un test de charge de plusieurs heures. LSLT 1.0 de lxml, la validation RelaxNG / XMLSchema / DTD et les extensions EXSLT n’ont pas du tout été testées ici — une vaste surface de capacité, mais au-delà du cœur parsing-sélection. J’ai observé le second plafond de profondeur à 2 045 sans déterminer 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 sans GIL n’ont pas été testés non plus. Et dans XPath lui-même, j’ai couvert les fonctions natives, mais pas les variables XPath, ni les fonctions d’extension Python personnalisées, ni la réutilisation d’objets etree.XPath précompilés.

Verdict

lxml n’est pas la nouvelle star rapide, et c’est précisément ce qui en fait une recommandation solide. C’est un binding libxml2 vieux de deux décennies avec un vrai moteur XPath 1.0 qu’aucune alternative Python grand public n’égale, trois niveaux de parsing prévisibles avec un journal d’erreurs au milieu, un parseur streaming réel pour les documents qui ne tiennent pas en mémoire, une gestion correcte des namespaces et des encodages, et une licence pleinement permissive. Les quelques aspérités — le plafond d’environ 253 niveaux et le résultat du threading avec parseur partagé — sont documentées, configurables, et désormais expliquées.

Si vous maîtrisez votre pipeline de scraping et que vous exploitez XPath, lxml reste le parseur à choisir. Si vous préférez ne pas gérer les sélecteurs et le rendu, c’est précisément le rôle d’une couche d’extraction IA comme l’API Thunderbit, MCP et CLI — une répartition claire des rôles, pas une rivalité. Dans tous les cas, considérez ces chiffres comme provisoires et revalidez les timings sur votre propre plateforme avant de les citer dans une note d’architecture.

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 binding Python vers libxml2/libxslt qui transforme du markup en arbre modifiable et interrogeable. Il ne récupère pas les pages, ne rend pas JavaScript et ne gère pas l’anti-bot ; c’est vous qui fournissez la couche requête (via requests, httpx, un navigateur headless ou un service de scraping), puis vous passez 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 parseur backend, mais n’expose aucun XPath natif ; selectolax est limité au CSS et plus rapide dans son créneau étroit. Si votre logique de sélection doit filtrer par contenu textuel, naviguer vers le parent ou l’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 trop profondément imbriqué ? Son parseur par défaut limite l’imbrication à environ 253 niveaux — une défense de libxml2 contre les documents hostiles, pas un bug. Réglez huge_tree=True (par exemple lxml.html.HTMLParser(huge_tree=True)) et il récupère entièrement les profondeurs 300 et 1 000. Notez toutefois un second plafond de récursion, plus strict, autour de 2 045 niveaux, que huge_tree ne supprime pas.

lxml libère-t-il le GIL pour le parsing multithread ? Seulement dans les bonnes conditions. La FAQ de lxml précise que le GIL est libéré pendant le parsing quand 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,21× avec 4 threads reflète le chemin naïf avec parseur partagé, pas le potentiel 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, il s’agit toujours d’une bibliothèque actuelle et bien supportée, pas d’un vestige.

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 de données web IA

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

Approuvé par plus de 250 000 utilisateurs
plan gratuit disponible
Extraire des données avec l'IA
Transférez facilement des données vers Google Sheets, Airtable ou Notion
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week