Cada pocos meses aparece un parser HTML más rápido, empiezan a circular los benchmark y alguien da por vieja a la vieja guardia. Luego intentas seleccionar cada párrafo que contiene una palabra concreta, o recuperar el padre de un nodo encontrado, y te acuerdas de por qué lxml sigue abierto en otra pestaña.
lxml es un binding de libxml2 con 20 años de recorrido. No llama la atención. No es nuevo. Y para una tarea muy concreta — todo lo que necesite un XPath de verdad — no hay nada más en el Python convencional que le haga sombra. Esta es una revisión práctica de lo que hace, dónde gana sin hacer ruido y cuáles son los pocos rincones en los que sus valores por defecto te pueden morder si no sabes que están ahí.
lxml en un párrafo: qué es realmente
lxml es un binding de Python para las librerías en C libxml2 y libxslt. Es un parser y un serializador, no un scraper ni un navegador: convierte marcado en un árbol que puedes consultar y editar, y luego vuelve a convertir ese árbol en bytes. Ofrece una API compatible con ElementTree, un motor completo de XPath 1.0, XSLT 1.0 y validación de esquemas, mantenido por Stefan Behnel bajo el lema "la biblioteca más completa y fácil de usar para procesar XML y HTML en el lenguaje Python".
Así se encuentra, según una instantánea de GitHub y PyPI tomada el 2026-07-14:
| Campo | Valor |
|---|---|
| Repositorio | lxml/lxml |
| Estrellas | 3,043 |
| Forks | 620 |
| Issues abiertas | 16 |
| Licencia | BSD-3-Clause |
| Creado | 2011-02-11 |
| Último push | 2026-07-02 |
| Estable en PyPI | 6.1.1 (2026-05-18) |
| Motor incluido | libxml2 2.14.6 + libxslt 1.1.43 |
Antes de que nadie me acuse de vender humo, dejo algo claro: en esta revisión no hay secretos. lxml tiene la suficiente trayectoria como para que todo lo que se menciona aquí esté documentado en algún sitio de la documentación de lxml, en un changelog de libxml2 o en un hilo de launchpad. No encontré ningún truco exclusivo y no voy a inventarlo. El valor de lo que sigue está en que está sistematizado, cuantificado y organizado alrededor de lxml como protagonista — no en que sea una novedad.
Configuración de pruebas (y por qué los números de tiempo son prestados)
En esta revisión entran dos categorías de datos y vienen de dos sitios distintos, así que voy a ser transparente sobre qué pertenece a cada una.
Las pruebas de capacidad — comportamiento de XPath, las dos APIs del parser, espacios de nombres, codificación, ciclo de vida de nodos — las ejecuté de nuevo en una máquina: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Cada número de esos archivos artifacts/raw/*.json lo calcula un script en ejecución, no lo escribí a mano. Las pruebas de capacidad son booleanos y enums deterministas, así que una sola ejecución es estable: la carga de la máquina no cambia si //a/@href devuelve una cadena de atributo.
Los números de tiempo y de huella de memoria no proceden de este paquete. Se reutilizan tal cual del paquete de benchmarks anterior de selectolax — misma máquina, mismo entorno virtual, mismo build de lxml y libxml2, benchmarks al 2026-07-13 — y aquí no los volví a ejecutar. Eso es deliberado. Ejecutar benchmarks de tiempo junto con un lote de scripts de capacidad introduce contención de CPU y contaminaría las cifras reutilizadas; además, sería trabajo duplicado: lxml ya era una librería de control completamente medida en ese paquete. Reutilizar el mismo banco de pruebas mantiene la comparación realista y evita introducir una segunda medición ligeramente distinta. Así que cuando veas una cifra en milisegundos más abajo, léela como "mismo banco de pruebas, al 2026-07-13", no como "lo medí otra vez hoy".
Los hallazgos llevan una etiqueta de confianza: single-observation para las pruebas deterministas de capacidad, triple-run para las distribuciones de tiempo reutilizadas, e hypothesis cuando propongo un mecanismo que no aislé.
XPath: lo único que selectolax y BeautifulSoup simplemente no tienen
Este es el titular, así que empiezo por aquí.

Pasé el xpath() de lxml por una matriz pre-registrada de 37 elementos: el resultado esperado de cada caso estaba escrito en el código antes de ejecutar la prueba, así que no podía calificarlo con benevolencia. Diez ejes, nueve estilos de predicado, diez funciones integradas, tres tipos de retorno escalar y cinco casos trampa deliberados usando sintaxis exclusiva de XPath 2.0 que el motor 1.0 de lxml debía rechazar.
| Categoría | Cobertura | Resultado |
|---|---|---|
| Ejes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 aprobadas |
| Predicados | [1] / last() / position()<n / igualdad de atributo / existencia de atributo / and / or / anidado [.//a] / not() | 9/9 aprobadas |
| Funciones | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 aprobadas |
| Tipos de retorno | boolean / number escalares | 3/3 aprobados |
| Casos trampa | matches() / secuencias / if-then-else / except / error de sintaxis | 5/5 rechazados correctamente |
La puntuación es 37/37, y la columna de trampas es la que importa. matches(), las expresiones de secuencia, if/then/else y except son sintaxis de XPath 2.0, y el motor 1.0 de libxml2 no las soporta a medias: lanza XPathEvalError y rechaza la consulta, en vez de devolver silenciosamente un conjunto de nodos incorrecto. Así que es una puntuación perfecta después de intentar romperlo, no una puntuación perfecta armada con casos fáciles. Todo lo que ves aquí coincide exactamente con lo que describen las docs de XPath de lxml, que es justo el punto.
Admito una cosa que el harness hizo mal, porque es la versión de "37/37" en la que de verdad puedes confiar. Mi primer conjunto esperado para //div[.//a[@href]] predecía dos coincidencias; la ejecución devolvió una. Durante unos treinta segundos pensé que lxml estaba mal, luego revisé el fixture y descubrí que el segundo elemento era un <footer>, no un <div>: el error era mío, no del motor. Corregí el conjunto esperado y dejé la equivocación en un comentario del código. Ese es el orden correcto de culpa: primero sospecha de tu propia prueba antes que de una librería C de 20 años.
XPath frente a CSS: lo que literalmente no puedes expresar en CSS
La afirmación abstracta de que "XPath es más potente" merece un número concreto, así que cuantifiqué la diferencia. lxml te da tanto .xpath() como .cssselect() (esta última traduce CSS a XPath por debajo). Tomé diez objetivos de selección y comprobé cuáles puede expresar realmente CSS.

| Objetivo | XPath | CSS (cssselect) |
|---|---|---|
Filtrar por contenido textual (contains(text(),"bargain")) | Sí | No hay selector de texto |
Seleccionar el padre a partir de un hijo (//b/parent::p) | Sí | No hay selector de padre |
Devolver el valor de un atributo (//a/@href) | Sí | Solo elementos |
Devolver un nodo de texto (//p/text()) | Sí | No hay nodos de texto |
Eje ancestor (//td/ancestor::div) | Sí | No hay navegación hacia arriba |
Filtrar el padre por número de hijos (//ul[count(li)=4]) | Sí | No hay predicado de conteo |
Filtrar por longitud del texto (string-length(text())>5) | Sí | No hay predicado de longitud |
nth-child / last-child / hermano adyacente | Sí | Sí (3 casos básicos) |
Siete de los diez objetivos no tienen equivalente en CSS. Filtrar por contenido textual, navegar hacia arriba hasta padres y ancestros, extraer como resultado un valor de atributo o un nodo de texto suelto, predicados basados en conteo: CSS no puede expresar nada de eso. Solo tres (nth-child, last-child, hermano adyacente) funcionan en ambos. Esa es la respuesta cuantificada a "qué gano realmente al usar lxml". selectolax es solo CSS y no tiene ningún método xpath(), así que esos siete tipos de consulta allí se convierten en bucles Python de varios pasos o directamente no existen. Si tu lógica de scraping depende de alguno de ellos, la decisión está tomada.
(Y sí, el harness me volvió a pillar aquí: predije un conjunto vacío para string-length(text())>5, pero aparecieron dos cadenas de seis caracteres. Corregí la expectativa, no la herramienta.)
Tres modos de estrictez: etree, recover y lxml.html
XPath es la razón para elegir lxml. El control de estrictez en tres niveles es la razón para seguir usándolo.

La mayoría de los parsers te dan un solo comportamiento ante entrada rota. lxml te da tres, y son lo bastante predecibles como para que yo pasara seis clases de marcado mal formado por cada uno y dejara registrado de antemano cómo debía comportarse cada ruta.
| Entrada mal formada | lxml.etree (estricto) | etree + recover=True | lxml.html (tolerante) |
|---|---|---|---|
Etiqueta sin cerrar <root><a>x</root> | lanza error | se recupera | acepta |
Anidado incorrectamente <b><i></b></i> | lanza error | se recupera | acepta |
Entidad no definida | lanza error | se recupera | acepta |
Ampersand suelto & (Tom & Jerry) | lanza error | se recupera | acepta |
Varias raíces <a>1</a><b>2</b> | lanza error | se recupera | acepta |
| XML bien formado | acepta | acepta (0 errores) | acepta |
Atributo booleano <input disabled> | lanza error | se recupera | acepta |
Siete de siete coincidieron con la expectativa pre-registrada. lxml.etree lanza XMLSyntaxError en las seis clases mal formadas. Si añades recover=True al mismo parser, traga los errores y reconstruye un árbol utilizable — y esta es la parte infravalorada — parser.error_log enumera después cada error que ha tenido que ignorar. lxml.html acepta todo sin quejarse.
El clasificador que decide "lanza error vs se recupera vs acepta" se basa en la longitud real de error_log, no en una constante, y por eso un documento bien formado ejecutado con recover=True queda correctamente etiquetado como "acepta" (log vacío) en lugar de "se recupera". Mi primera versión de ese clasificador marcaba cualquier resultado con recover=True como "se recupera" y etiquetaba mal la entrada limpia; leer el error_log real corrigió eso.
Qué te aporta esto en la práctica: validación estricta cuando un feed roto debe fallar en voz alta, usa lxml.etree. HTML sucio del mundo real que solo necesitas atravesar, usa lxml.html. Y el caso intermedio que la mayoría de las herramientas no cubren — "sé flexible, pero dime exactamente qué estaba mal para poder registrarlo" — usa recover=True y lee el error log. selectolax tiene el modo tolerante y nada más: ni modo estricto ni error log.
iterparse: el modo de streaming que selectolax no tiene en absoluto
Esto es una línea de capacidad, no un control de velocidad. selectolax solo ingiere una cadena completa — no tiene interfaz incremental. El iterparse de lxml va entregando elementos a medida que se cierran, y combinado con el patrón clásico fast_iter (llamar a elem.clear() y borrar los hermanos anteriores mientras avanzas) mantiene la memoria plana por muy grande que sea el documento.

Medí la memoria directamente — pico de RSS mediante ru_maxrss, cada sujeto en su propio proceso limpio, sobre 300,000 elementos <record> que sumaban unos 15 MB.
| Modo | Delta pico de RSS | Notas |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | se libera sobre la marcha; plano independientemente del número |
iterparse sin clear | ~386 MB | conserva referencias; tan pesado como cargar todo |
etree.parse (carga completa, referencia) | ~386 MB | pesado por diseño; demuestra que el medidor capta el orden de magnitud |
El modo acotado mantiene el delta pico de RSS en aproximadamente 1-2 MB frente a unos ~386 MB de una carga completa: una diferencia de magnitud del 0.3-0.4%, y el evento del primer registro aparece antes incluso de que el archivo termine de leerse, así que es de verdad incremental, no un streaming falso. La línea instructiva es la del medio. Ejecuta el mismo bucle iterparse pero omite clear(), y la memoria vuelve a subir a ~386 MB, porque estás reteniendo referencias a todo. La ganancia vive en clear(), no en iterparse por sí solo. La referencia de carga completa, mucho más alta que el modo acotado, también confirma que el medidor RSS sí capta la diferencia de magnitud y no está midiendo a ciegas. (Esta prueba de memoria la ejecuté en este paquete — es una medición de huella, distinta de los números de tiempo prestados.)
La versión real de esto: una exportación XML de varios gigabytes que no cabe en RAM no tiene ningún camino equivalente en selectolax. O usas el parser de streaming de lxml o cambias de lenguaje.
Espacios de nombres: RSS, SVG y la trampa del namespace por defecto
Doce casos de namespace, cubriendo RSS a través de tres espacios de nombres, SVG con un namespace por defecto más xlink, y XML con namespace por defecto. Los doce aprobaron.
lxml extrae //dc:creator/text() de un feed RSS exactamente como ["Alice", "Bob"], resuelve //atom:link/@href y //content:encoded a través de tres namespaces distintos en el mismo documento, maneja //s:rect y //s:use/@xlink:href en el segundo namespace de SVG, separa nombres Clark {uri}local con QName e inspecciona los namespaces mediante nsmap. Este es el comportamiento documentado y mantenido, y es una dimensión completa que selectolax ni toca, porque selectolax solo trabaja con HTML5 y no procesa namespaces XML arbitrarios.
Hay una trampa documentada que conviene memorizar. XPath no tiene concepto de namespace por defecto. Si apuntas //book a un documento que declara xmlns="urn:...", obtendrás cero coincidencias: el prefijo vacío no está definido para XPath, tal como explican las docs de lxml. Tienes que enlazar un prefijo artificial (//c:book con namespaces={"c": "urn:..."}, que encontró los tres) o recurrir a //*[local-name()='book'] (también tres). No es un bug: es la especificación de XPath, implementada fielmente. Solo sorprende a todo el mundo una vez.
Páginas reales y sucias: fidelidad en 11 scrapes auténticos
Las pruebas sintéticas son limpias; la web no. Reutilicé once páginas reales capturadas del conjunto de fixtures del paquete de selectolax (al 2026-07-10, solo lectura) y pasé lxml.html por ellas teniendo a lxml como sujeto.
| Fixture | Tamaño | Links | errores recuperados por libxml2 | XML estricto |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | lanza error |
| wiki_scraping.html | 227 KB | 460 | 0 | lanza error |
| gov_whitehouse.html | 289 KB | 154 | 0 | lanza error |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | lanza error |
| forum_reddit.html | 129 KB | 318 | 0 | lanza error |
| docs_python.html | 80 KB | 341 | 2 | lanza error |
| ecommerce_books.html | 51 KB | 94 | 0 | lanza error |
| news_hackernews.html | 35 KB | 229 | 0 | lanza error |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | lanza error |
| spa_quotes_js.html | 6 KB | 5 | 0 | lanza error |
Las once se parsearon con lxml.html, y los conteos de links, títulos e imágenes coincidieron con los conteos reutilizados de lxml del paquete de selectolax en las once: verificación cruzada true. Esa coincidencia es lo que me dice que la reutilización es realmente comparable y no dos mediciones distintas con la misma etiqueta.
La observación lateral: el parser XML estricto lanzó error en diez de las once páginas. Las páginas reales de la web casi nunca están bien formadas como XML, que es exactamente por lo que existe el modo de recuperación HTML de libxml2. La única excepción fue BBC News, renderizada con Next.js y lo bastante bien formada como para sobrevivir al parser XML estricto. No todo lo que se etiqueta como "HTML" necesita el modo de recuperación.
Un detalle de conteo en el que es fácil tropezar. En docs_python.html, //a[@href] (existencia de atributo) contó 343, mientras que el if n.get("href") del paquete de selectolax (valor truthy) contó 341. Las dos de más son enlaces con href="" vacío. Eso es una diferencia de convención de conteo — atributo existente frente a atributo no vacío — no una diferencia de comportamiento de lxml, y los números se reconcilian en cuanto alineas el predicado. Conviene saberlo al raspar: si los href vacíos cuentan o no, lo decide tu filtro, no el parser.
El límite de profundidad que parece un bug (pero no lo es)
El paquete de selectolax había registrado que lxml perdía el contenido más profundo en marcado de <div> anidados a 1,000 y 5,000 niveles, y lo describía como "lxml pierde silenciosamente el contenido más profundo". Quería el mecanismo, así que ejecuté el parser por defecto frente a huge_tree=True.

| Profundidad solicitada | El parser por defecto alcanza | huge_tree=True alcanza |
|---|---|---|
| 300 | 253 (descarta el resto) | 299 (recuperado) |
| 1000 | 253 (descarta el resto) | 999 (recuperado) |
| 5000 | 253 (descarta el resto) | 2045 (sigue descartando) |
El parser por defecto trunca alrededor de 253 niveles y descarta silenciosamente todo lo más profundo. Eso no es un bug: es una defensa DoS de libxml2, un límite de anidamiento de unas 256 capas que evita que un documento hostil desborde la pila, y está documentado en el hilo de launchpad de lxml sobre XML_PARSE_HUGE. Si activas huge_tree=True, las profundidades de 300 y 1,000 vuelven completas. Pero a 5,000 solo llega a 2,045 incluso con huge_tree activado: hay un segundo techo de recursión de libxml2, más duro, por encima del configurable, y huge_tree no lo elimina.
Así que la acción concreta es clara: cuando parses marcado profundo de una fuente confiable, usa lxml.html.HTMLParser(huge_tree=True). Lo que este paquete añade sobre la observación reutilizada es el mecanismo (una barrera de seguridad, no corrupción de datos), la solución (huge_tree) y el hecho de que existe un segundo techo al que la solución no llega.
DOM de lectura/escritura, serialización y codificación
lxml es un árbol completo de lectura/escritura, no un extractor de solo lectura, y verifiqué la superficie de edición caso por caso. Las ocho operaciones DOM aprobaron: SubElement, insert, remove, replace, strip_tags (quita etiquetas pero conserva su texto), strip_elements (quita etiquetas y su texto), drop_tree (exclusivo de lxml.html) y el modelo de doble ranura text/tail, que suele despistar a quien empieza: en <p>head<b>bold</b>tail</p>, p.text es "head", b.text es "bold" y b.tail es "tail".
La serialización aprobó cinco de cinco: tostring en modos XML y HTML (HTML deja correctamente sin autocierre los void elements), pretty_print, canonicalización C14N (method="c14n", otro exclusivo de lxml) y un round-trip limpio.
La codificación es donde lxml se diferencia en silencio. Si le pasas bytes que no son UTF-8 — "<p>café éè</p>".encode("latin-1") mediante lxml.html.fromstring — recupera café éè intacto, sin caracteres de reemplazo U+FFFD ni bytes perdidos. Esto reproduce directamente su papel como la "referencia limpia" en el paquete de selectolax, donde la misma entrada se corrompía silenciosamente en los otros dos motores (Lexbor producía caracteres de reemplazo, Modest eliminaba bytes). La detección de charset basada en libxml2 es sencillamente más estable aquí.
La contrapartida es la rigidez a la hora de cómo declaras la codificación. encoding="latin-1" en una declaración XML lanza XMLSyntaxError: Unsupported encoding: latin-1, mientras que el nombre canónico de la IANA encoding="ISO-8859-1" se analiza bien y devuelve café. libxml2 solo acepta nombres canónicos de codificación, no alias: un detalle documentado ya en launchpad #613302. Molesto si no lo sabes, trivial una vez que lo sabes.
Por último, el ciclo de vida de nodos. Ejecuté tres escenarios de manejadores obsoletos en subprocesos aislados (un fallo grave aparecería como un código de salida distinto de cero): conservar un nodo después de que su árbol haya sido recogido por el garbage collector, leer un handle después de drop_tree() y usar un nodo después de remove(). No hubo segfaults en ninguno: lxml mantiene viva la referencia del nodo a su árbol para evitar use-after-free. El mismo parte de salud limpia que obtuvo selectolax en esta prueba.
Velocidad y memoria (prestadas, y siendo honestos sobre ello)
Todo en esta sección está reutilizado del paquete de selectolax, al 2026-07-13. Este paquete no produjo ninguna cifra de tiempo propia, y prefiero decirlo dos veces antes que hacerte pensar que re-medí algo.
| Dimensión | Valor lxml | Lectura |
|---|---|---|
| p50 de parseo puro (10 MB) | 77.9 ms | ~33-34% más rápido que selectolax-Lexbor |
| p50 de parseo completo + extracción (1 MB / 10 MB) | 14.18 ms / 172.9 ms | prácticamente igual que Lexbor en tamaños pequeños |
| Throughput CSS sobre 100k nodos | 3,002,646 nodos/s | el más rápido entre los tres motores en C |
| Delta RSS en 10 MB | 128.9 MB | el más ligero de los seis parsers, ~1.7x más eficiente que BeautifulSoup |
| Arranque en frío de importación | 14.1 ms | ~2.3x más rápido que imports estilo parsel |
Las cifras de parseo puro y throughput son sólidas, y lxml es el más ahorrador en memoria de los seis parsers medidos. Pero el panorama de threading necesita una salvedad. Los datos reutilizados muestran una mejora de tiempo total con 4 hilos de solo 1.21x, marcada como inconclusa — pero eso corresponde a la ruta con parser compartido por defecto. La FAQ de lxml es explícita: el GIL se libera durante el parseo solo cuando cada hilo usa su propio parser (o una copia del default); compartir un parser serializa el acceso. Verifiqué estructuralmente la API para hacerlo bien (XMLParser.copy() existe, get/set_default_parser existen, XPathEvaluator lleva un bloqueo interno), pero no medí la ganancia con parser por hilo — eso sería una medición nueva de tiempo, y este paquete no produce ese tipo de cifras. Así que lee "1.21x" como "en la ruta ingenua con parser compartido", no como el techo de threading de lxml.
Y una nota al margen sobre todo esto: son números de una sola plataforma, macOS arm64. La afirmación de que lxml gana a Lexbor en parseo puro va contra el consenso habitual de que el parser basado en Lexbor es el más rápido, así que de verdad merece una comprobación en Linux x86_64 antes de que alguien lo trate como definitivo.
Licencia: la victoria aburrida
lxml se distribuye bajo BSD-3-Clause, y las bibliotecas en C que incluye — libxml2 y libxslt — son ambas MIT. Es una cadena totalmente permisiva, sin copyleft en ninguna parte, algo que importa en cuanto redistribuyes. A modo de contraste, la wheel de selectolax incluye Modest con LGPL-2.1 y Lexbor con Apache-2.0, así que lxml tiene una historia más limpia para integrarlo en un producto cerrado.
También hay una ventaja práctica de instalación: lxml publica wheels precompiladas que enlazan estáticamente libxml2 y libxslt, así que pip install lxml normalmente no necesita ni libxml2 del sistema ni compilador en tu máquina — una experiencia distinta a compilarlo desde cero.
Dónde encaja lxml — y dónde entra en juego una capa de extracción con IA
Conviene dejar clara la frontera, porque es fácil equivocarse de categoría. lxml es una biblioteca de parseo. Te entrega un árbol y un motor de consultas excelente, y todo lo que rodea ese árbol sigue siendo tu responsabilidad: descargar la página, renderizar JavaScript, esquivar defensas anti-bot, escribir y mantener el XPath y estructurar el resultado. Eso es una capa distinta a un servicio de extracción alojado, y no compiten tanto como que son vecinos.
Para quien prefiere no encargarse del stack completo de fetch-render-select-maintain, esa capa superior es donde vive algo como Thunderbit — y para este público importa su API, el servidor MCP y la CLI, no la extensión del navegador. La Thunderbit Open API expone POST /distill para convertir una página en Markdown limpio y POST /extract para extraer datos estructurados según un esquema JSON, con un conmutador renderMode y trabajos por lotes para grandes volúmenes. El mismo motor está disponible como servidor MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) para agentes y asistentes de programación, y como CLI que puedes ejecutar desde la terminal con npx @thunderbit/thunderbit-cli. Se encarga del renderizado JS, las defensas anti-bot y los CAPTCHAs de forma nativa, y devuelve JSON ajustado al esquema — es decir, la capa por encima del parseo, no un sustituto.
Prueba Thunderbit para extraer datos web
El planteamiento es sencillo. Usa lxml cuando controlas el pipeline y quieres un XPath quirúrgico sobre un árbol que entiendes. Usa una API de extracción con IA cuando prefieres no mantener selectores ni renderizado. En sistemas reales, muchas veces se usan ambas: lxml para los feeds estructurados que controlas, y un servicio de extracción para las páginas largas y sucias que no controlas.
Lo que esta revisión no probó
Esta es una revisión provisional, no una nota final, así que aquí va lo que no cubre.
Todas las cifras de tiempo y memoria se reutilizan, son de una sola plataforma (macOS arm64, Python 3.14) y heredan las advertencias de ese paquete: el resultado de "lxml es más rápido en parseo puro" va contra el consenso habitual y necesita una nueva comprobación en Linux x86_64. La mejora de threading por parser por hilo no está probada (requeriría nuevos tiempos). Medí la memoria de iterparse sobre 300k registros, pero no XML real a escala de gigabytes, ni iterparse sobre HTML frente a XML, ni una prueba de duración de varias horas. El XSLT 1.0 de lxml, su validación RelaxNG / XMLSchema / DTD y las extensiones EXSLT no se probaron aquí en absoluto: una superficie de capacidades enorme, pero fuera del núcleo de parseo y selección. Observé el segundo techo de profundidad en 2,045, pero no fijé la constante exacta de recursión de libxml2. Solo se probó la versión estable 6.1.1, no la alpha 7.0.0. Windows, builds desde fuente y la versión 3.14t libre de GIL no se probaron. Y dentro del propio XPath, cubrí funciones integradas pero no variables XPath, funciones de extensión Python personalizadas ni la reutilización de objetos precompilados etree.XPath.
Veredicto
lxml no es la novedad rápida del momento, y precisamente por eso merece la recomendación. Es un binding de libxml2 con dos décadas de historia, con un motor completo de XPath 1.0 que ninguna alternativa mainstream de Python iguala, tres niveles de estrictez de parseo predecibles con un error log en medio, un parser de streaming real para documentos que no caben en memoria, manejo correcto de múltiples namespaces y de la codificación, y una licencia completamente permisiva. Los pocos bordes afilados — el límite de profundidad de ~253 niveles y el dato de threading con parser compartido — están documentados, se pueden ajustar y ahora están explicados.
Si controlas tu pipeline de scraping y dependes de XPath, lxml sigue siendo el parser al que debes acudir. Si prefieres no mantener selectores ni renderizado, para eso existe una capa de extracción con IA como la API, MCP y CLI de Thunderbit: una división de tareas limpia, no una competición. En cualquier caso, trata estas cifras como provisionales y vuelve a comprobar el rendimiento en tu propia plataforma antes de citarlo en un documento de diseño.
Prueba Thunderbit para extraer datos web Get Started Free
Preguntas frecuentes
¿lxml es un web scraper?
No. lxml es un parser y serializador: un binding de Python para libxml2/libxslt que convierte marcado en un árbol editable y consultable. No descarga páginas, no renderiza JavaScript ni gestiona defensas anti-bot; tú aportas la capa de petición (con requests, httpx, un navegador headless o un servicio de scraping) y le pasas los bytes a lxml.
¿Cuándo debería usar lxml en lugar de BeautifulSoup o selectolax? Usa lxml cuando necesites XPath. BeautifulSoup puede usar lxml como parser de backend, pero no expone XPath nativo, y selectolax es solo CSS y más rápido en su nicho estrecho. Si tu lógica de selección necesita filtrar por contenido textual, navegar a padres o ancestros, extraer atributos o nodos de texto, o usar predicados de conteo, el motor XPath de lxml es la única opción habitual en Python que lo expresa directamente.
¿Por qué lxml descarta silenciosamente contenido muy anidado?
Su parser por defecto limita el anidamiento a unas 253 capas: una defensa de libxml2 contra documentos hostiles, no un bug. Activa huge_tree=True (por ejemplo, lxml.html.HTMLParser(huge_tree=True)) y recuperará por completo profundidades de 300 y 1,000. Ojo: existe un segundo techo de recursión más duro, alrededor de 2,045 niveles, que huge_tree no elimina.
¿lxml libera el GIL al parsear en varios hilos? Solo bajo las condiciones correctas. La FAQ de lxml indica que el GIL se libera durante el parseo cuando cada hilo usa su propio parser o una copia del parser por defecto; si compartes un parser, el acceso se serializa. La mejora reutilizada de 1.21x con 4 hilos refleja la ruta ingenua con parser compartido, no el techo real con parser por hilo, que no se midió aquí.
¿lxml sigue mantenido en 2026? Sí. La versión estable 6.1.1 salió el 2026-05-18, el repositorio recibió su último push el 2026-07-02 y hay una alpha 7.0.0 en progreso. Con unas 3,000 estrellas en GitHub y libxml2 activamente mantenido por debajo, sigue siendo una biblioteca vigente y bien soportada, no una reliquia.


