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 :
| Champ | Valeur |
|---|---|
| Dépôt | lxml/lxml |
| Étoiles | 3 043 |
| Forks | 620 |
| Problèmes ouverts | 16 |
| Licence | BSD-3-Clause |
| Créé le | 2011-02-11 |
| Dernier push | 2026-07-02 |
| Version stable PyPI | 6.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à.

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égorie | Couverture | Résultat |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 réussis |
| Prédicats | [1] / last() / position()<n / égalité d'attribut / existence d'attribut / and / or / [.//a] imbriqué / not() | 9/9 réussis |
| Fonctions | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 réussies |
| Types de retour | booléen / scalaire numérique | 3/3 réussis |
| Cas-pièges | matches() / séquences / if-then-else / except / erreur de syntaxe | 5/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.

| Cible | XPath | CSS (cssselect) |
|---|---|---|
Filtrer par contenu textuel (contains(text(),"bargain")) | Oui | Pas de prédicat texte |
Sélectionner le parent depuis l’enfant (//b/parent::p) | Oui | Pas de sélecteur parent |
Renvoyer une valeur d’attribut (//a/@href) | Oui | Uniquement des éléments |
Renvoyer un nœud texte (//p/text()) | Oui | Pas de nœuds texte |
Axe ancestor (//td/ancestor::div) | Oui | Pas de navigation vers le haut |
Filtrer un parent par nombre d’enfants (//ul[count(li)=4]) | Oui | Pas de prédicat de comptage |
Filtrer par longueur de texte (string-length(text())>5) | Oui | Pas de prédicat de longueur |
nth-child / last-child / frère adjacent | Oui | Oui (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.

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ée | lxml.etree (strict) | etree + recover=True | lxml.html (tolérant) |
|---|---|---|---|
Balise non fermée <root><a>x</root> | lève une erreur | récupère | accepte |
Imbrication incorrecte <b><i></b></i> | lève une erreur | récupère | accepte |
Entité non définie | lève une erreur | récupère | accepte |
Esperluette brute & (Tom & Jerry) | lève une erreur | récupère | accepte |
Plusieurs racines <a>1</a><b>2</b> | lève une erreur | récupère | accepte |
| XML bien formé | accepte | accepte (0 erreur) | accepte |
Attribut booléen <input disabled> | lève une erreur | récupère | accepte |
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.

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.
| Mode | Delta RSS de pic | Notes |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | libération au fil de l’eau ; plat quel que soit le volume |
iterparse sans clear | ~386 MB | conserve les références ; aussi lourd qu’un chargement complet |
etree.parse (chargement complet, référence) | ~386 MB | lourd 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.
| Fixture | Taille | Liens | erreurs récupérées par libxml2 | XML strict |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | erreur levée |
| wiki_scraping.html | 227 KB | 460 | 0 | erreur levée |
| gov_whitehouse.html | 289 KB | 154 | 0 | erreur levée |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | erreur levée |
| forum_reddit.html | 129 KB | 318 | 0 | erreur levée |
| docs_python.html | 80 KB | 341 | 2 | erreur levée |
| ecommerce_books.html | 51 KB | 94 | 0 | erreur levée |
| news_hackernews.html | 35 KB | 229 | 0 | erreur levée |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | erreur levée |
| spa_quotes_js.html | 6 KB | 5 | 0 | erreur 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.

| Profondeur demandée | Profondeur atteinte par le parseur par défaut | Profondeur atteinte avec huge_tree=True |
|---|---|---|
| 300 | 253 (le reste est perdu) | 299 (récupéré) |
| 1000 | 253 (le reste est perdu) | 999 (récupéré) |
| 5000 | 253 (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.
| Dimension | Valeur lxml | Lecture |
|---|---|---|
| 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œuds | 3 002 646 nœuds/s | dans la tranche la plus rapide des trois moteurs C |
| Delta RSS sur 10 MB | 128,9 MB | le plus sobre des six parseurs, ~1,7× plus économe que BeautifulSoup |
| Démarrage à froid à l’import | 14,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.


