Cada pocos meses aparece un parser HTML más veloz, empiezan a circular los benchmarks y alguien da por pasada de moda a la vieja guardia. Luego intentas seleccionar cada párrafo que contiene cierta palabra, o sacar el padre de un nodo que coincide, y te acuerdas de por qué lxml sigue abierto en otra pestaña.
lxml es un binding de libxml2 con 20 años de historia. No es emocionante. No es nuevo. Y para una tarea muy concreta — cualquier cosa que necesite un verdadero XPath — no hay nada en el Python mainstream que le haga sombra de verdad. Esta es una reseña práctica de lo que hace, dónde gana sin hacer ruido y en qué par de casos sus valores por defecto te pueden jugar una mala pasada si no los conoces.
lxml en un párrafo: qué es realmente
lxml es un binding de Python para las bibliotecas en C libxml2 y libxslt. Es un parser y serializador, no un scraper ni un navegador: convierte el 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 rica en funcionalidades y fácil de usar para procesar XML y HTML en el lenguaje Python".
Así se encontraba, según una instantánea de GitHub y PyPI tomada el 2026-07-14:
| Campo | Valor |
|---|---|
| Repositorio | lxml/lxml |
| Estrellas | 3,043 |
| Forks | 620 |
| Incidencias 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 exagerar, conviene dejar algo claro: en esta reseña no hay secretos. lxml ya tiene suficiente tiempo como para que cualquier comportamiento aquí esté documentado en algún rincón de los docs de lxml, en un changelog de libxml2 o en un hilo de Launchpad. No encontré ningún truco exclusivo ni sin documentación, y no voy a inventarlo. El valor de lo que sigue es que está sistematizado, medido y organizado alrededor de lxml como tema — no porque traiga una primicia.
Cómo se hizo la prueba (y por qué las cifras de tiempo son prestadas)
En esta reseña entran dos tipos de datos, y vienen de dos sitios distintos, así que prefiero ser transparente sobre cuál es cuál.
Las pruebas de capacidad — comportamiento de XPath, las dos APIs de 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 en los archivos artifacts/raw/*.json se calcula mediante un script ejecutado, no escrito 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 consumo de memoria no pertenecen a este pack. Se reutilizan literalmente del pack de benchmarks de selectolax anterior — misma máquina, mismo entorno virtual, misma compilación de lxml y libxml2, benchmarks al 2026-07-13 — y no los volví a ejecutar aquí. Es intencional. Repetir benchmarks de tiempo junto con scripts de capacidad invita a la contención de CPU y contaminaría las cifras reutilizadas; además, sería trabajo duplicado: lxml ya funcionaba como biblioteca de control completamente medida en ese pack. Reutilizar el mismo bench mantiene la comparación homogénea en lugar de introducir una segunda medición ligeramente distinta. Así que cuando veas una cifra en milisegundos abajo, léela como "mismo banco de pruebas, al 2026-07-13", no como "esto lo medí hoy otra vez".
Los hallazgos llevan una etiqueta de confianza: single-observation para las pruebas deterministas de capacidad, triple-run para las distribuciones de tiempo reutilizadas, hypothesis cuando propongo un mecanismo que no aislé.
XPath: la única cosa que selectolax y BeautifulSoup simplemente no tienen
Este es el titular, así que empiezo por aquí.

Pasé xpath() de lxml por una matriz de 37 elementos, pre-registrada: el resultado esperado para cada caso quedó escrito en el código antes de ejecutar la prueba, así que no podía calificar sobre la marcha. Diez ejes, nueve estilos de predicado, diez funciones integradas, tres tipos escalares de retorno y cinco casos trampa deliberados usando sintaxis exclusiva de XPath 2.0 que el motor 1.0 de lxml debería rechazar.
| Categoría | Cobertura | Resultado |
|---|---|---|
| Ejes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predicados | [1] / last() / position()<n / igualdad de atributo / existencia de atributo / and / or / anidado [.//a] / not() | 9/9 pass |
| Funciones | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 pass |
| Tipos de retorno | boolean / number scalars | 3/3 pass |
| Casos trampa | matches() / sequences / if-then-else / except / error de sintaxis | 5/5 correctamente rechazados |
La puntuación es 37/37, y la columna de trampas es la que importa. matches(), las expresiones de secuencias, if/then/else y except son sintaxis de XPath 2.0, y el motor 1.0 de libxml2 no las admite a medias: lanza XPathEvalError y se niega, en lugar de devolver en silencio un conjunto de nodos incorrecto. Así que es una puntuación perfecta después de intentar romperlo, no una puntuación armada con preguntas fáciles. Todo lo observado aquí coincide exactamente con lo que describen los docs de XPath de lxml, que es justo la idea.
Reconozco una cosa que el harness interpretó mal, porque es la versión de "37/37" en la que sí 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 se había equivocado, pero luego revisé el fixture y vi que el segundo elemento era un <footer>, no un <div>: mi expectativa estaba mal, no el motor. Corregí el conjunto esperado y dejé el error como comentario en el código. Ese es el orden correcto de culpas: sospecha primero de tu prueba antes de acusar a una biblioteca C de 20 años.
XPath vs CSS: lo que literalmente no puedes expresar en CSS
La afirmación abstracta de que "XPath es más potente" merece una cifra concreta, 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 CSS realmente.

| Objetivo | XPath | CSS (cssselect) |
|---|---|---|
Filtrar por contenido textual (contains(text(),"bargain")) | Sí | No hay predicado de texto |
Seleccionar el padre a partir del hijo (//b/parent::p) | Sí | No hay selector de padre |
Devolver un valor de 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 de texto (string-length(text())>5) | Sí | No hay predicado de longitud |
nth-child / last-child / hermano adyacente | Sí | Sí (3 básicos) |
Siete de los diez objetivos no tienen equivalente CSS en absoluto. Filtrado por contenido de texto, navegación ascendente hacia padres y ancestros, extracción de un atributo o de un nodo de texto puro como resultado, 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 de verdad al usar lxml". selectolax es solo CSS y no tiene método xpath() en absoluto, así que esos siete tipos de consulta allí se convierten en bucles Python de varios pasos o simplemente no se pueden hacer. Si tu lógica de scraping depende de alguno de ellos, ya sabes qué decisión has tomado.
(Y sí, el harness me volvió a pillar aquí: predije un conjunto vacío para string-length(text())>5, pero dos cadenas de seis caracteres coincidieron. Corregí la expectativa, no la herramienta.)
Tres niveles de rigor: etree, recover y lxml.html
XPath es la razón para elegir lxml. El control de rigor en tres velocidades es la razón para quedarte con él.

La mayoría de parsers te dan un solo comportamiento ante entrada rota. lxml te da tres, y son lo bastante predecibles como para que alimentara seis clases de marcado mal formado a cada uno, con expectativa registrada de antemano sobre cómo debería comportarse cada ruta.
| Entrada malformada | lxml.etree (estricto) | etree + recover=True | lxml.html (tolerante) |
|---|---|---|---|
Etiqueta sin cerrar <root><a>x</root> | lanza error | recupera | acepta |
Anidado incorrecto <b><i></b></i> | lanza error | recupera | acepta |
Entidad no definida | lanza error | recupera | acepta |
& suelto (Tom & Jerry) | lanza error | recupera | acepta |
Múltiples raíces <a>1</a><b>2</b> | lanza error | recupera | acepta |
| XML bien formado | acepta | acepta (0 errores) | acepta |
Atributo booleano <input disabled> | lanza error | recupera | acepta |
Siete de siete coincidieron con lo previsto. lxml.etree lanza XMLSyntaxError en las seis clases mal formadas. Si añades recover=True al mismo parser, se traga los errores y reconstruye un árbol utilizable — y esta es la parte subestimada — parser.error_log enumera entonces todos los errores que se tragó. lxml.html acepta todo sin quejarse.
El clasificador que decide entre "lanza", "recupera" o "acepta" se basa en la longitud de error_log en tiempo de ejecución, no en valores fijos, por eso un documento bien formado ejecutado con recover=True queda correctamente etiquetado como "acepta" (registro vacío) y no como "recupera". Mi primera versión de ese clasificador etiquetaba cualquier resultado de recover=True como "recupera" y marcaba mal la entrada limpia; leer el error_log real lo solucionó.
Lo que esto te da en la práctica: validación estricta cuando un feed roto debe fallar con ruido, usa lxml.etree. HTML sucio del mundo real que solo necesitas atravesar, usa lxml.html. Y el caso intermedio que la mayoría de herramientas no sabe manejar — "sé tolerante, pero dime exactamente qué estaba roto para que pueda registrarlo" — usa recover=True y lee el log de errores. selectolax tiene la marcha tolerante y nada más: ni modo estricto ni log de errores.
iterparse: la marcha de streaming que selectolax no tiene en absoluto
Esto es una línea de capacidad, no un dial de velocidad. selectolax solo ingiere una cadena completa: no hay interfaz incremental. 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 sin importar lo grande que sea el documento.

Medí directamente el comportamiento de memoria — pico de RSS vía ru_maxrss, cada sujeto en su propio proceso limpio, sobre 300,000 elementos <record> con un total de unos 26.7 MB (26,744,801 bytes).
| Modo | Delta pico de RSS | Notas |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | se libera sobre la marcha; plano independientemente del conteo |
iterparse sin clear | ~386 MB | conserva referencias; tan pesado como una carga completa |
etree.parse (carga completa, ancla) | ~386 MB | conocido por ser pesado; demuestra que el medidor capta la magnitud |
El modo limitado mantiene el delta de RSS pico en aproximadamente 1-2 MB frente a los ~386 MB de una carga completa — una diferencia de magnitud de 0.3-0.4% — y el primer evento de record aparece antes de que el archivo termine de leerse, así que es realmente incremental, no un streaming fingido. 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 sosteniendo referencias a todo. La ganancia vive en clear(), no en iterparse por sí solo. La lectura ancla de carga completa muy por encima del modo limitado también confirma que el medidor RSS puede ver la diferencia de magnitud y no está leyendo a ciegas. (Esta prueba de memoria sí la ejecuté en este pack: es una medición de huella, distinta de las cifras de tiempo prestadas.)
La versión real de esto: una exportación XML de varios gigabytes que no cabe en RAM no tiene ninguna ruta con selectolax. Es el parser en streaming de lxml o un lenguaje distinto.
Espacios de nombres: RSS, SVG y la trampa del namespace por defecto
Doce casos de namespaces, cubriendo RSS en tres namespaces, SVG con un namespace por defecto más xlink, y XML con namespace por defecto. Los doce pasaron.
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-notation {uri}local con QName e inspecciona con nsmap. Este es el comportamiento mantenido y documentado, y es una dimensión completa que selectolax no toca, porque selectolax solo trabaja con HTML5 y no procesa namespaces XML arbitrarios.
Hay una trampa documentada que merece memorizarse. XPath no tiene concepto de namespace por defecto. Si apuntas //book a un documento que declara xmlns="urn:...", obtendrás cero resultados: el prefijo vacío no está definido para XPath, tal como explican los docs de lxml. Tienes que vincular 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. Simplemente sorprende a todo el mundo una vez.
Páginas realmente sucias: fidelidad en 11 scrapes reales
Las pruebas sintéticas son limpias; la web no lo es. Reutilicé once páginas reales capturadas del conjunto de fixtures del pack de selectolax (al 2026-07-10, solo lectura) y sometí lxml.html a ellas, con 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 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
Las once páginas se analizaron con lxml.html, y los conteos de enlaces, encabezados e imágenes coincidieron con los conteos de lxml reutilizados del pack de selectolax en las once: verificación cruzada true. Esa coincidencia es lo que me indica que la reutilización es realmente comparable y no dos mediciones distintas con la misma etiqueta.
El hallazgo lateral: el parser XML estricto lanzó error en diez de las once páginas. Las páginas web reales casi nunca son XML bien formado, que es exactamente por lo que existe el modo de recuperación HTML de libxml2 para tragárselas. La única excepción fue BBC News, renderizado con Next.js y lo bastante bien formado como para sobrevivir al parsing XML estricto. No todo lo que se etiqueta como "HTML" necesita el camino 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 pack de selectolax con if n.get("href") (valor verdadero) contó 341. Los dos extra son enlaces con href="" vacío. Eso es una diferencia de convención de conteo — el atributo existe frente a el atributo no está vacío — no una diferencia de comportamiento de lxml, y las cifras se reconcilian en cuanto alineas el predicado. Conviene tenerlo presente al scrapear: si los href vacíos cuentan o no depende de tu filtro, no del parser.
El límite de profundidad que parece un bug (pero no lo es)
El pack de selectolax había registrado que lxml perdía el contenido más profundo en marcado <div> de 1,000 y 5,000 niveles, y lo presentó como "lxml pierde silenciosamente el contenido más profundo". Quería conocer 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 (aún descarta) |
El parser por defecto trunca en torno a 253 niveles y descarta en silencio lo que queda más profundo. Eso no es un bug: es la defensa DoS de libxml2, un límite de anidamiento de alrededor de 256 niveles que evita que un documento hostil reviente la pila, y está documentado en el hilo de Launchpad de lxml sobre XML_PARSE_HUGE. Si activas huge_tree=True, las profundidades 300 y 1,000 vuelven completas. Pero a 5,000, incluso con huge_tree, solo se llega a 2,045: hay un segundo techo de recursión más duro en libxml2 por encima del configurable, y huge_tree no lo elimina.
La acción concreta, entonces, es esta: cuando analices marcado muy profundo procedente de una fuente de confianza, usa lxml.html.HTMLParser(huge_tree=True). Lo que aporta este pack sobre la observación reutilizada es el mecanismo (un límite 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, codificación
lxml es un árbol completo de lectura y escritura, no un extractor de solo lectura, y verifiqué la superficie de edición caso por caso. Pasaron las ocho operaciones DOM: SubElement, insert, remove, replace, strip_tags (quita las etiquetas, conserva su texto), strip_elements (quita las etiquetas y su texto), drop_tree (exclusiva de lxml.html), y el modelo de dos ranuras text/tail que suele confundir a los recién llegados: en <p>head<b>bold</b>tail</p>, p.text es "head", b.text es "bold" y b.tail es "tail".
La serialización pasó cinco de cinco: tostring en modo XML y HTML (HTML deja correctamente los elementos vacíos sin autocerrarlos), pretty_print, canonicalización C14N (method="c14n", otra exclusividad de lxml) y un round-trip limpio.
La codificación es donde lxml se diferencia sin hacer ruido. Dale bytes que no son UTF-8 — "<p>café éè</p>".encode("latin-1") a través de lxml.html.fromstring — y recupera café éè intacto, sin caracteres de reemplazo U+FFFD ni bytes perdidos. Eso reproduce directamente su papel como "referencia limpia" en el pack de selectolax, donde la misma entrada se corrompía en silencio bajo los otros dos motores (Lexbor producía caracteres de reemplazo, Modest descartaba bytes por completo). La detección de charset basada en libxml2 es simplemente más estable aquí.
La contrapartida es la rigidez sobre cómo declaras una codificación. encoding="latin-1" en una declaración XML lanza XMLSyntaxError: Unsupported encoding: latin-1, mientras que el nombre canónico IANA encoding="ISO-8859-1" se analiza bien y devuelve café. libxml2 solo acepta nombres canónicos de encoding, no alias: un detalle documentado desde launchpad #613302. Molesto si no lo sabes, trivial cuando lo sabes.
Por último, el ciclo de vida de los nodos. Ejecuté tres escenarios de handles obsoletos en subprocesos aislados (un crash fuerte se vería como un código de salida distinto de cero): conservar un nodo después de que su árbol es recolectado por el GC, leer un handle después de drop_tree() y usar un nodo después de remove(). Ningún segfault en ninguno de ellos: lxml mantiene viva la referencia del nodo a su árbol para evitar use-after-free. Misma nota limpia que obtuvo selectolax en esta prueba.
Velocidad y memoria (prestadas, y siendo honestos con ello)
Todo en esta sección se reutiliza del pack de selectolax, al 2026-07-13. Este pack no produjo ninguna cifra de tiempo propia, y prefiero decirlo dos veces antes que hacerte creer que volví a medir algo.
| Dimensión | Valor de 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 | aproximadamente a la par con Lexbor en tamaños pequeños |
| Throughput CSS en 100k nodos | 3,002,646 nodos/s | el nivel más rápido de 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 liviano 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 parser más ahorrativo en memoria de los seis medidos. Sin embargo, hay que añadir una nota sobre hilos. Los datos reutilizados muestran una mejora wall-clock de solo 1.21x con 4 hilos, marcada como inconclusa — pero eso corresponde a la ruta con parser compartido por defecto. Las FAQ de lxml explican claramente que el GIL se libera durante el parseo solo cuando cada hilo usa su propio parser (o una copia del predeterminado); un parser compartido serializa el acceso. Verifiqué estructuralmente la superficie de API para hacerlo bien (XMLParser.copy() existe, get/set_default_parser existen, XPathEvaluator lleva un lock interno), pero no medí la aceleración con parser por hilo — eso sería una nueva medición de tiempo, y este pack no produce ese tipo de dato. Así que lee "1.21x" como "bajo la ruta compartida ingenua", no como el techo de hilos de lxml.
Y un asterisco sobre todo ello: estas son cifras de una sola plataforma, macOS arm64. La afirmación de que el parseo puro de lxml supera a Lexbor va contra el consenso habitual de que el parser respaldado por Lexbor es el más rápido, así que realmente conviene repetirlo en Linux x86_64 antes de darlo por cerrado.
Licencia: la victoria aburrida
lxml se distribuye bajo BSD-3-Clause, y las bibliotecas C que incluye — libxml2 y libxslt — son ambas MIT. Es una cadena totalmente permisiva, sin copyleft en ningún punto, algo que importa en cuanto redistribuyes. Como contraste, el wheel de selectolax incluye Modest bajo LGPL-2.1 y Lexbor bajo Apache-2.0, así que lxml ofrece una historia más limpia para enviar dentro de un producto cerrado.
También hay una ventaja práctica al instalarlo: lxml publica wheels precompilados que enlazan estáticamente libxml2 y libxslt, así que pip install lxml normalmente no necesita libxml2 del sistema ni un compilador en tu máquina, una experiencia distinta a compilarlo desde cero.
Dónde encaja lxml — y dónde entra una capa de extracción con IA
Conviene dejar muy clara la frontera, porque aquí es fácil equivocarse de categoría. lxml es una biblioteca de parsing. Te entrega un árbol y un motor de consulta sobresaliente; todo lo que rodea ese árbol sigue siendo tu responsabilidad: obtener la página, renderizar JavaScript, sortear defensas anti-bot y construir y mantener el XPath, además de estructurar el resultado. Esa es otra capa distinta de un servicio de extracción gestionado, y ambas no son tanto rivales como vecinas.
Para un desarrollador que preferiría no encargarse del stack de fetch-render-select-maintain, esa capa superior es donde vive algo como Thunderbit — y para esta audiencia hablamos de la API, el servidor MCP y la CLI, no de 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 a partir de un JSON Schema, con un interruptor renderMode y trabajos por lotes para volumen. 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 ejecutable directamente desde una terminal con npx @thunderbit/thunderbit-cli. Maneja renderizado JS, anti-bot y CAPTCHAs de forma nativa y devuelve JSON que coincide con el esquema, que es una capa por encima del parsing, no un reemplazo.
Probar Thunderbit para extracción de datos web
La idea es simple. Usa lxml cuando controlas la tubería y quieres un control quirúrgico de XPath sobre un árbol que entiendes. Usa una API de extracción con IA cuando prefieres no mantener selectores ni renderizado. Muchos sistemas reales usan ambas cosas: lxml para los feeds estructurados que controlan y un servicio de extracción para las páginas desordenadas de la larga cola que no controlan.
Lo que esta reseña no probó
Esto es una reseña provisional, no una tarjeta de puntuación 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 pack: el resultado de "lxml es más rápido en parseo puro" va contra el consenso y necesita una nueva verificación en Linux x86_64. La mejora de threading con parser por hilo no se ha probado (requeriría nuevos tiempos). Medí la memoria de iterparse en 300k registros, pero no XML real a escala de gigabytes, ni iterparse en HTML frente a XML, ni una prueba de resistencia de varias horas. XSLT 1.0 de lxml, la validación RelaxNG / XMLSchema / DTD y las extensiones EXSLT no se probaron aquí en absoluto: una gran superficie de capacidades, pero fuera del núcleo de parsing 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 estable 6.1.1, no la alpha 7.0.0. Windows, compilaciones desde fuente y la build free-threaded 3.14t tampoco se probaron. Y dentro del propio XPath, cubrí funciones integradas pero no variables XPath, funciones personalizadas de Python ni la reutilización de objetos etree.XPath precompilados.
Veredicto
lxml no es la cosa nueva y rápida, y precisamente por eso lo recomiendo. Es un binding de libxml2 con dos décadas de historia, con un motor XPath 1.0 completo que ninguna alternativa mainstream de Python iguala, tres niveles previsibles de rigor de parsing con un log de errores en el medio, un parser streaming real para documentos que no caben en memoria, manejo correcto de múltiples namespaces y de codificación, y una licencia totalmente permisiva. Los pocos bordes afilados — el límite de profundidad de ~253 niveles y la cifra de hilos con parser compartido — están documentados, se pueden configurar 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 limpia del trabajo, no una competencia. En cualquier caso, trata estas cifras como provisionales y vuelve a comprobar el tiempo en tu propia plataforma antes de citarlo en un documento de diseño.
Probar Thunderbit para extracción de 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 el marcado en un árbol editable y consultable. No descarga páginas, no renderiza JavaScript ni maneja defensas anti-bot; tú aportas la capa de petición (con requests, httpx, un navegador headless o un servicio de scraping) y entregas los bytes a lxml.
¿Cuándo debo usar lxml en lugar de BeautifulSoup o selectolax? Elige lxml cuando necesites XPath. BeautifulSoup puede usar lxml como parser de backend, pero no expone XPath nativo, y selectolax solo usa CSS y es más rápido en su nicho estrecho. Si tu lógica de selección necesita filtrado por texto, navegación a padres o ancestros, extracción de atributos o nodos de texto, o predicados de conteo, el motor XPath de lxml es la única opción mainstream de Python que lo expresa directamente.
¿Por qué lxml descarta en silencio contenido muy anidado?
Su parser por defecto limita el anidamiento a unas 253 capas: es la defensa DoS de libxml2 frente a documentos hostiles, no un bug. Activa huge_tree=True (por ejemplo lxml.html.HTMLParser(huge_tree=True)) y recupera por completo profundidades de 300 y 1,000. Ojo con un segundo techo, más duro, alrededor de 2,045 niveles, que huge_tree no elimina.
¿lxml libera el GIL al analizar en multihilo? Solo en las condiciones adecuadas. La FAQ de lxml dice que el GIL se libera durante el parsing cuando cada hilo usa su propio parser o una copia del parser predeterminado; un parser compartido serializa el acceso. La mejora reutilizada de 1.21x con 4 hilos refleja la ruta ingenua con parser compartido, no el techo 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 desarrollo. Con unas 3,000 estrellas en GitHub y libxml2 activamente mantenido debajo, sigue siendo una biblioteca actual y bien respaldada, no una reliquia.


