A cada poucos meses aparece um parser HTML mais rápido, os benchmark viram manchete e alguém já declara que os veteranos ficaram ultrapassados. Aí você precisa selecionar todos os parágrafos que contêm certa palavra, ou pegar o elemento pai de um nó encontrado, e lembra por que o lxml continua aberto em outra aba.
lxml é um binding de 20 anos para libxml2. Não é chamativo. Não é novo. E, para uma tarefa específica — qualquer coisa que realmente dependa de XPath — nada no Python mainstream consegue competir. Esta é uma análise prática do que ele faz, onde vence sem fazer alarde e em quais pontos seus padrões podem te pegar desprevenido se você não souber que eles estão lá.
lxml em um parágrafo: o que ele realmente é
lxml é um binding de Python para as bibliotecas em C libxml2 e libxslt. É um parser e serializador, não um scraper e não um navegador — ele transforma marcação em uma árvore que você pode consultar e editar, e converte a árvore de volta em bytes. Ele oferece uma API compatível com ElementTree, um mecanismo completo de XPath 1.0, XSLT 1.0 e validação de schemas, mantido por Stefan Behnel sob o lema "a biblioteca mais rica em recursos e fácil de usar para processar XML e HTML na linguagem Python".
Veja onde ele está, com base em um snapshot do GitHub e do PyPI coletado em 2026-07-14:
| Campo | Valor |
|---|---|
| Repositório | lxml/lxml |
| Estrelas | 3.043 |
| Forks | 620 |
| Issues abertas | 16 |
| Licença | BSD-3-Clause |
| Criado em | 2011-02-11 |
| Último push | 2026-07-02 |
| Versão estável no PyPI | 6.1.1 (2026-05-18) |
| Engine incluído | libxml2 2.14.6 + libxslt 1.1.43 |
Antes que alguém me acuse de exagero, vale deixar uma coisa clara: esta análise não esconde segredos. lxml já é antigo o bastante para que todo comportamento descrito aqui apareça documentado em algum lugar da documentação do lxml, num changelog do libxml2 ou em um thread do Launchpad. Não encontrei truques exclusivos e não documentados, e não vou inventar nenhum. O valor do que vem a seguir está em organizar, quantificar e estruturar lxml como objeto de estudo — não em trazer novidades.
Configuração dos testes (e por que os números de tempo são reaproveitados)
Dois grupos de dados entram nesta análise, e eles vêm de lugares diferentes, então vou deixar isso explícito.
Os testes de capacidade — comportamento de XPath, as duas APIs de parser, namespaces, encoding, ciclo de vida dos nós — foram executados de novo, nesta máquina: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Cada número nesses arquivos artifacts/raw/*.json é calculado por um script no momento da execução, não digitado manualmente. Testes de capacidade são booleanos e enums determinísticos, então uma única execução é estável — a carga da máquina não muda se //a/@href retorna uma string de atributo.
Os números de tempo e uso de memória não são deste pacote. Eles foram reaproveitados literalmente do pacote anterior de benchmark do selectolax — mesma máquina, mesmo ambiente virtual, mesma compilação de lxml e libxml2, benchmarks em 2026-07-13 — e eu não os reexecutei aqui. Isso é intencional. Reexecutar benchmark de tempo junto com um lote de scripts de capacidade pode introduzir contenção de CPU e contaminar os números reaproveitados, além de gerar trabalho duplicado: lxml já era uma biblioteca de controle totalmente medida naquele pacote. Reutilizar o mesmo benchmark mantém tudo comparável, sem introduzir uma segunda medição sutilmente diferente. Então, quando você vir um valor em milissegundos abaixo, leia como "mesmo conjunto de testes, em 2026-07-13", e não "eu refiz a medição hoje".
Os achados vêm com uma tag de confiança: single-observation para os testes determinísticos de capacidade, triple-run para as distribuições de tempo reaproveitadas, hypothesis quando estou propondo um mecanismo que não isolei.
XPath: a única coisa que selectolax e BeautifulSoup simplesmente não têm
Este é o ponto principal, então vou começar por ele.

Submeti o xpath() do lxml a uma matriz predefinida de 37 itens — o resultado esperado para cada caso foi escrito no código antes da execução, então eu não poderia, sem querer, avaliar com indulgência. Dez eixos, nove estilos de predicado, dez funções embutidas, três tipos de retorno escalar e cinco casos armadilha deliberados usando sintaxe exclusiva do XPath 2.0, que o mecanismo 1.0 do lxml deveria rejeitar.
| Categoria | Cobertura | Resultado |
|---|---|---|
| Eixos | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 aprovados |
| Predicados | [1] / last() / position()<n / igualdade de atributo / existência de atributo / and / or / [.//a] aninhado / not() | 9/9 aprovados |
| Funções | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 aprovados |
| Tipos de retorno | booleanos / escalares numéricos | 3/3 aprovados |
| Casos armadilha | matches() / sequências / if-then-else / except / erro de sintaxe | 5/5 rejeitados corretamente |
A pontuação é 37/37, e a coluna das armadilhas é a parte mais importante. matches(), expressões em sequência, if/then/else e except são sintaxe de XPath 2.0, e o motor 1.0 do libxml2 não os suporta pela metade — ele lança XPathEvalError e recusa a consulta, em vez de devolver silenciosamente um conjunto de nós errado. Então é uma pontuação perfeita depois de tentar quebrar o sistema, não uma perfeição montada com casos fáceis. Todo comportamento aqui corresponde exatamente ao que a documentação de XPath do lxml descreve, e esse é o ponto.
Vou admitir uma coisa que o harness errou, porque é a única versão de "37/37" na qual você pode confiar. Meu primeiro conjunto esperado para //div[.//a[@href]] previa dois resultados; a execução retornou um. Por cerca de trinta segundos eu achei que o lxml estivesse errado, até conferir a fixture e ver que o segundo elemento era um <footer>, não um <div> — a expectativa estava errada, não o mecanismo. Corrigi o conjunto esperado e deixei o erro em um comentário no código. Essa é a ordem correta de culpa: desconfie do seu teste antes de desconfiar de uma biblioteca em C com 20 anos.
XPath vs CSS: o que você literalmente não consegue expressar em CSS
A afirmação abstrata de que "XPath é mais poderoso" merece um número concreto, então quantifiquei a diferença. O lxml oferece tanto .xpath() quanto .cssselect() (este último traduz CSS para XPath por baixo dos panos). Peguei dez alvos de seleção e verifiquei quais deles o CSS consegue realmente expressar.

| Alvo | XPath | CSS (cssselect) |
|---|---|---|
Filtrar por conteúdo textual (contains(text(),"bargain")) | Sim | Sem predicado de texto |
Selecionar o pai a partir do filho (//b/parent::p) | Sim | Sem seletor de pai |
Retornar um valor de atributo (//a/@href) | Sim | Apenas elementos |
Retornar um nó de texto (//p/text()) | Sim | Sem nós de texto |
Eixo ancestor (//td/ancestor::div) | Sim | Sem navegação para cima |
Filtrar pai por quantidade de filhos (//ul[count(li)=4]) | Sim | Sem predicado de contagem |
Filtrar por tamanho do texto (string-length(text())>5) | Sim | Sem predicado de comprimento |
nth-child / last-child / irmão adjacente | Sim | Sim (3 casos básicos) |
Sete dos dez alvos não têm equivalente em CSS. Filtragem por conteúdo textual, navegação para cima até pais e ancestrais, retorno de valor de atributo ou de nó de texto puro, predicados baseados em contagem — CSS não expressa nada disso. Só três (nth-child, last-child, irmão adjacente) funcionam nos dois. Essa é a resposta quantificada para "o que eu realmente ganho ao usar lxml". selectolax é apenas CSS e nem sequer tem método xpath(), então esses sete tipos de consulta viram, naquele caso, loops Python em múltiplas etapas ou simplesmente não acontecem. Se sua lógica de scraping depende de qualquer um deles, a decisão já está tomada.
(E sim, o harness me pegou de novo aqui: eu previa um conjunto vazio para string-length(text())>5, mas duas strings de seis caracteres passaram. Corrigi a expectativa, não a ferramenta.)
Três níveis de rigor: etree, recover e lxml.html
XPath é o motivo para escolher lxml. O controle de rigidez em três modos é o motivo para manter o lxml.

A maioria dos parsers oferece um único comportamento para entrada quebrada. lxml oferece três, e eles são previsíveis o suficiente para que eu tenha passado seis classes de marcação malformada por cada um e pré-registrado como cada caminho deveria se comportar.
| Entrada malformada | lxml.etree (estrito) | etree + recover=True | lxml.html (tolerante) |
|---|---|---|---|
Tag não fechada <root><a>x</root> | lança erro | recupera | aceita |
Aninhamento incorreto <b><i></b></i> | lança erro | recupera | aceita |
Entidade indefinida | lança erro | recupera | aceita |
E comercial solto & (Tom & Jerry) | lança erro | recupera | aceita |
Múltiplas raízes <a>1</a><b>2</b> | lança erro | recupera | aceita |
| XML bem formado | aceita | aceita (0 erros) | aceita |
Atributo booleano <input disabled> | lança erro | recupera | aceita |
Sete de sete corresponderam à expectativa pré-registrada. lxml.etree lança XMLSyntaxError em todas as seis classes malformadas. Adicione recover=True ao mesmo parser e ele engole os erros e reconstrói uma árvore utilizável — e essa é a parte subestimada — parser.error_log então enumera todos os erros que ele engoliu. lxml.html aceita tudo sem reclamar.
O classificador que decide "lançou erro vs recuperou vs aceitou" é acionado pela contagem em tempo de execução de error_log, e não por um valor fixo, o que faz com que um documento bem formado processado com recover=True receba corretamente o rótulo "aceita" (log vazio), e não "recupera". A primeira versão desse classificador marcava qualquer resultado com recover=True como "recupera" e rotulou errado a entrada limpa; ler o error_log de fato resolveu isso.
O que isso significa na prática: se você quer validação rígida e um feed quebrado deve falhar alto, use lxml.etree. Se for HTML sujo do mundo real, que você só precisa conseguir processar, use lxml.html. E o caso intermediário que a maioria das ferramentas não resolve — "seja tolerante, mas me diga exatamente o que estava quebrado para eu registrar" — use recover=True e leia o log de erros. selectolax tem o modo tolerante e mais nada, sem modo estrito e sem log de erros.
iterparse: o modo de streaming que selectolax não tem de forma alguma
Aqui estamos falando de uma capacidade, não de um ajuste de velocidade. selectolax só consome uma string inteira — não há interface incremental. O iterparse do lxml emite elementos conforme eles são fechados e, combinado com o padrão clássico fast_iter (chamar elem.clear() e apagar os irmãos anteriores à medida que avança), mantém a memória estável não importa o tamanho do documento.

Medi a característica de memória diretamente — pico de RSS via ru_maxrss, cada sujeito em um processo limpo, com 300.000 elementos <record> totalizando cerca de 15 MB.
| Modo | Delta de RSS no pico | Observações |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | liberado conforme avança; plano independentemente da contagem |
iterparse sem clear | ~386 MB | mantém referências; tão pesado quanto carregar tudo |
etree.parse (carga completa, referência) | ~386 MB | pesado por definição; prova que o medidor capta a ordem de grandeza |
O modo limitado mantém o delta de RSS no pico em algo como 1-2 MB contra ~386 MB de uma carga completa — uma diferença de magnitude de 0,3-0,4% — e o primeiro evento de registro acontece antes mesmo de o arquivo terminar de ser lido, então é incremental de verdade, não um pseudo-streaming. A linha mais instrutiva é a do meio. Rode o mesmo loop de iterparse mas sem clear(), e a memória volta a subir para ~386 MB, porque você está segurando referências a tudo. O ganho está em clear(), não em iterparse sozinho. A leitura de referência em carga completa, muito acima do modo limitado, também confirma que o medidor de RSS consegue enxergar essa diferença de magnitude, em vez de medir às cegas. (Este teste de memória foi executado neste pacote — é uma medição de footprint, distinta dos números de tempo reaproveitados.)
A versão real disso é simples: um exportação XML de vários gigabytes que não cabe na RAM não tem caminho algum em selectolax. É o parser em streaming do lxml ou outra linguagem.
Namespaces: RSS, SVG e a armadilha do namespace padrão
Doze casos de namespace, cobrindo RSS em três namespaces, SVG com namespace padrão mais xlink, e XML com namespace padrão. Todos os doze passaram.
lxml extrai //dc:creator/text() de um feed RSS como exatamente ["Alice", "Bob"], resolve //atom:link/@href e //content:encoded através de três namespaces diferentes no mesmo documento, lida com //s:rect e //s:use/@xlink:href no segundo namespace do SVG, separa nomes no formato Clark {uri}local com QName e inspeciona via nsmap. Esse é o comportamento documentado e mantido, e é uma dimensão inteira que selectolax não toca, porque selectolax é apenas HTML5 e não processa namespaces XML arbitrários.
Há uma armadilha documentada que vale decorar. XPath não tem conceito de namespace padrão. Aponte //book para um documento que declara xmlns="urn:..." e você obtém zero resultados — o prefixo vazio é indefinido para XPath, como a documentação do lxml explica. É preciso associar um prefixo artificial (//c:book com namespaces={"c": "urn:..."}, o que encontrou os três) ou cair em //*[local-name()='book'] (também três). Não é bug — é a especificação de XPath, implementada fielmente. Só surpreende todo mundo uma vez.
Páginas realmente sujas: fidelidade em 11 scrapes reais
Testes sintéticos são limpos; a web não é. Reutilizei onze páginas reais capturadas do conjunto de fixtures do pacote selectolax (em 2026-07-10, somente leitura) e passei lxml.html por elas, com lxml como objeto de estudo.
| Fixture | Tamanho | Links | Erros recuperados pelo libxml2 | XML estrito |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | lançou erro |
| wiki_scraping.html | 227 KB | 460 | 0 | lançou erro |
| gov_whitehouse.html | 289 KB | 154 | 0 | lançou erro |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | lançou erro |
| forum_reddit.html | 129 KB | 318 | 0 | lançou erro |
| docs_python.html | 80 KB | 341 | 2 | lançou erro |
| ecommerce_books.html | 51 KB | 94 | 0 | lançou erro |
| news_hackernews.html | 35 KB | 229 | 0 | lançou erro |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | lançou erro |
| spa_quotes_js.html | 6 KB | 5 | 0 | lançou erro |
Todos os onze foram analisados com lxml.html, e as contagens de links, títulos e imagens coincidiram com as contagens reaproveitadas do lxml no pacote selectolax em todos os onze — conferência cruzada true. Esse acordo é o que me diz que o reaproveitamento é realmente comparável e não duas medições diferentes usando o mesmo rótulo.
O achado lateral: o parser XML estrito lançou erro em dez das onze páginas. Na prática, páginas web reais quase nunca são XML bem formado, que é exatamente por isso que o modo de recuperação do libxml2 existe. A única exceção foi o BBC News, renderizado com Next.js e suficientemente bem formado para sobreviver ao parser XML estrito. Nem tudo que se chama "HTML" precisa do modo de recuperação.
Um detalhe de contagem que é fácil de confundir. Em docs_python.html, //a[@href] (existência de atributo) contou 343, enquanto o if n.get("href") do pacote selectolax (valor truthy) contou 341. Os dois extras são links com href="" vazio. Isso é diferença de convenção de contagem — existência de atributo versus atributo não vazio — e não diferença de comportamento do lxml; as contagens se reconciliam quando você alinha o predicado. Vale a pena saber disso ao fazer scraping: se href vazio conta ou não é uma escolha do seu filtro, não do parser.
O limite de profundidade que parece bug — mas não é
O pacote selectolax havia registrado o lxml perdendo o conteúdo mais profundo em marcação <div> aninhada a 1.000 e 5.000 níveis, e descreveu isso como "lxml perde silenciosamente o conteúdo mais profundo". Eu queria o mecanismo, então rodei o parser padrão contra huge_tree=True.

| Profundidade solicitada | O parser padrão alcança | huge_tree=True alcança |
|---|---|---|
| 300 | 253 (descarta o resto) | 299 (recuperado) |
| 1000 | 253 (descarta o resto) | 999 (recuperado) |
| 5000 | 253 (descarta o resto) | 2045 (ainda descarta) |
O parser padrão trunca em cerca de 253 níveis e descarta silenciosamente tudo o que está mais fundo. Isso não é bug — é a defesa DoS do libxml2, um limite de aninhamento de cerca de 256 níveis que impede que um documento malicioso estoure a pilha, e isso está documentado no thread do Launchpad do lxml sobre XML_PARSE_HUGE. Configure huge_tree=True e as profundidades 300 e 1.000 voltam por completo. A profundidade 5.000, porém, só alcança 2.045 mesmo com huge_tree ativado — existe um segundo limite mais rígido de recursão no libxml2, acima do configurável, e huge_tree não o remove.
Então a ação prática é concreta: quando você estiver analisando marcação profunda de uma fonte confiável, use lxml.html.HTMLParser(huge_tree=True). O que este pacote acrescenta à observação reaproveitada é o mecanismo (um limite de segurança, não corrupção de dados), a correção (huge_tree) e o fato de existir um segundo teto que a correção não alcança.
DOM de leitura/escrita, serialização e encoding
lxml é uma árvore completa de leitura e escrita, não um extrator somente leitura, e eu verifiquei a superfície de edição caso a caso. Todas as oito operações de DOM passaram: SubElement, insert, remove, replace, strip_tags (remove tags, mantém o texto), strip_elements (remove tags e o texto delas), drop_tree (exclusivo do lxml.html) e o modelo duplo de text/tail, que confunde quem está começando — em <p>head<b>bold</b>tail</p>, p.text é "head", b.text é "bold" e b.tail é "tail".
A serialização passou em cinco de cinco: tostring nos modos XML e HTML (o HTML preserva corretamente elementos vazios sem auto-fechamento), pretty_print, canonicalização C14N (method="c14n", outro recurso exclusivo do lxml) e um round-trip limpo.
Encoding é o ponto em que lxml se destaca sem fazer alarde. Dê a ele bytes não UTF-8 — "<p>café éè</p>".encode("latin-1") via lxml.html.fromstring — e ele recupera café éè intacto, sem caracteres de substituição U+FFFD, sem bytes descartados. Isso reproduz diretamente seu papel como a "referência limpa" no pacote selectolax, onde a mesma entrada era corrompida silenciosamente nos outros dois engines (Lexbor produziu caracteres de substituição, Modest descartou bytes). A detecção de charset baseada em libxml2 é simplesmente mais estável aqui.
O reverso é a rigidez em relação a como você declara o encoding. encoding="latin-1" numa declaração XML lança XMLSyntaxError: Unsupported encoding: latin-1, enquanto o nome canônico da IANA encoding="ISO-8859-1" é aceito e retorna café. O libxml2 só aceita nomes canônicos de encoding, não apelidos — um detalhe documentado lá atrás no launchpad #613302. Chato se você não souber; trivial depois que aprende.
Por fim, o ciclo de vida dos nós. Rodei três cenários de handle obsoleto em subprocessos isolados (uma falha grave apareceria como saída diferente de zero): manter um nó depois que sua árvore é coletada pelo garbage collector, ler um handle depois de drop_tree() e usar um nó depois de remove(). Nenhum segfault em qualquer um deles — o lxml mantém viva a referência do nó à árvore para evitar use-after-free. Mesma boa nota que o selectolax obteve nesse teste.
Velocidade e memória (reaproveitadas, e com honestidade)
Tudo nesta seção foi reaproveitado do pacote selectolax, em 2026-07-13. Este pacote não produziu nenhum número de tempo próprio, e eu prefiro dizer isso duas vezes a fazer você pensar que reexecutei algo.
| Dimensão | Valor do lxml | Leitura |
|---|---|---|
| p50 de parsing puro (10 MB) | 77,9 ms | ~33-34% mais rápido que selectolax-Lexbor |
| p50 de parsing completo + extração (1 MB / 10 MB) | 14,18 ms / 172,9 ms | aproximadamente empatado com Lexbor em tamanhos pequenos |
| Throughput CSS com 100k nós | 3.002.646 nós/s | faixa mais rápida entre os três engines em C |
| Delta de RSS em 10 MB | 128,9 MB | o mais enxuto entre seis parsers, ~1,7x mais leve que BeautifulSoup |
| Import cold start | 14,1 ms | ~2,3x mais rápido que imports no estilo parsel |
Os números de parsing puro e throughput são fortes, e o lxml é o mais econômico em memória entre os seis parsers medidos. Mas o quadro de multithreading precisa de uma ressalva. Os dados reaproveitados mostram um ganho de wall-clock de apenas 1,21x com 4 threads, marcado como inconclusivo — mas isso é o caminho com parser padrão compartilhado. A FAQ do lxml é explícita ao dizer que o GIL só é liberado durante o parsing quando cada thread usa seu próprio parser (ou uma cópia do parser padrão); um parser compartilhado serializa o acesso. Eu verifiquei estruturalmente a superfície da API para fazer isso corretamente (XMLParser.copy() existe, get/set_default_parser existem, XPathEvaluator carrega um lock interno), mas não medi o ganho com um parser por thread — isso exigiria uma nova medição de tempo, e este pacote não produz esse tipo de número. Então leia "1,21x" como "no caminho ingênuo com parser compartilhado", e não como o teto do threading no lxml.
E há um asterisco em tudo isso: são números de uma única plataforma, macOS arm64. A afirmação de que o parsing puro do lxml supera Lexbor contraria a percepção usual de que o parser baseado em Lexbor é o mais rápido, então isso realmente merece uma rechecagem em Linux x86_64 antes de alguém tratar o resultado como definitivo.
Licenciamento: a vitória sem glamour
lxml é distribuído sob BSD-3-Clause, e as bibliotecas C que ele inclui — libxml2 e libxslt — são ambas MIT. Isso forma uma cadeia totalmente permissiva, sem copyleft em nenhum ponto, o que importa assim que você for redistribuir. Em contraste, o wheel do selectolax inclui LGPL-2.1 para Modest e Apache-2.0 para Lexbor, então lxml tem uma história mais simples para embarcar em produto fechado.
Há também uma vantagem prática na instalação: lxml publica wheels pré-compilados que vinculam estaticamente libxml2 e libxslt, então pip install lxml normalmente não exige libxml2 no sistema nem um compilador na sua máquina — uma experiência diferente de compilar a partir do código-fonte.
Onde o lxml entra — e onde uma camada de extração com IA assume
É hora de deixar claro o limite, porque é fácil confundir as categorias. lxml é uma biblioteca de parsing. Ela te entrega uma árvore e um mecanismo de consulta excelente, e todo o resto em torno dessa árvore continua sendo sua responsabilidade: buscar a página, renderizar JavaScript, passar por defesas anti-bot, escrever e manter o XPath e estruturar o resultado. Isso é uma camada diferente de um serviço de extração hospedado, e os dois não são tanto rivais quanto vizinhos.
Para o desenvolvedor que prefere não assumir a pilha de buscar-renderizar-selecionar-manter, essa camada superior é onde algo como o Thunderbit vive — e, para este público, estamos falando da API, do servidor MCP e do CLI, não da extensão de navegador. A Thunderbit Open API expõe POST /distill para transformar uma página em Markdown limpo e POST /extract para extrair dados estruturados com base em um JSON Schema, com um seletor renderMode e jobs em lote para volume. O mesmo mecanismo também está disponível como servidor MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) para agentes e assistentes de programação, e como CLI que você roda direto do terminal via npx @thunderbit/thunderbit-cli. Ele lida com renderização JavaScript, anti-bot e CAPTCHAs fora da caixa e devolve JSON compatível com schema — ou seja, uma camada acima do parsing, não um substituto para ele.
Experimente o Thunderbit para extração de dados da web
O enquadramento é simples. Use lxml quando você controla a pipeline e quer precisão cirúrgica de XPath sobre uma árvore que conhece. Use uma API de extração com IA quando não quiser manter seletores e renderização. Muitos sistemas reais usam os dois — lxml para os feeds estruturados que controlam e um serviço de extração para as páginas bagunçadas e de cauda longa que não controlam.
O que esta análise não testou
Esta é uma análise provisória, não uma ficha final, então aqui está o que ela não cobre.
Todos os números de tempo e memória são reaproveitados, de uma única plataforma (macOS arm64, Python 3.14), e herdam as ressalvas daquele pacote — o resultado de que "lxml é mais rápido no parsing puro" vai contra o consenso e merece rechecagem em Linux x86_64. O ganho de threading com parser por thread não foi testado (exigiria novos timings). Medi o uso de memória do iterparse em 300 mil registros, mas não XML real em escala de gigabytes, nem iterparse em HTML versus XML, nem uma execução longa de várias horas. O XSLT 1.0 do lxml, a validação RelaxNG / XMLSchema / DTD e as extensões EXSLT não foram testados aqui — uma superfície enorme de capacidades, mas além do núcleo de parsing e seleção. Observei o segundo limite de profundidade em 2.045, mas não determinei a constante exata de recursão do libxml2. Testei apenas a versão estável 6.1.1, não o alpha 7.0.0. Windows, builds a partir do código-fonte e a versão 3.14t free-threaded também não foram testados. E dentro do próprio XPath, cobri funções embutidas, mas não variáveis XPath, funções Python de extensão personalizadas ou o reuso de objetos etree.XPath pré-compilados.
Veredito
lxml não é a novidade da vez, e é justamente por isso que ele merece a recomendação. É um binding do libxml2 com duas décadas de história e um motor XPath 1.0 completo que nenhuma alternativa mainstream em Python iguala, três modos previsíveis de rigidez de parsing com um log de erros no meio, um parser real de streaming para documentos que não cabem na memória, tratamento correto de múltiplos namespaces e encoding, e uma licença totalmente permissiva. Os poucos pontos duros — o limite de profundidade em ~253 níveis e o número de threading com parser compartilhado — são documentados, configuráveis e agora explicados.
Se você controla sua pipeline de scraping e depende de XPath, lxml ainda é o parser a que você deve recorrer. Se você prefere não manter seletores e renderização, é para isso que existe uma camada de extração com IA como a API, MCP e CLI do Thunderbit — uma divisão limpa de responsabilidades, não uma competição. Em qualquer caso, trate estes números como provisórios e revalide os timings na sua própria plataforma antes de citá-los em um documento de arquitetura.
Experimente o Thunderbit para extração de dados da web Get Started Free
FAQs
lxml é um web scraper?
Não. lxml é um parser e serializador — um binding em Python para libxml2/libxslt que transforma marcação em uma árvore editável e consultável. Ele não busca páginas, não renderiza JavaScript e não lida com defesas anti-bot; você fornece a camada de requisição (via requests, httpx, um navegador headless ou um serviço de scraping) e entrega os bytes para o lxml.
Quando devo usar lxml em vez de BeautifulSoup ou selectolax? Escolha lxml quando precisar de XPath. O BeautifulSoup até pode usar lxml como backend de parsing, mas não expõe XPath nativamente, e selectolax é só CSS e mais rápido no seu nicho estreito. Se sua lógica de seleção precisa filtrar por conteúdo textual, navegar para pais ou ancestrais, extrair atributos/nós de texto ou usar predicados de contagem, o motor XPath do lxml é a única opção mainstream em Python que expressa isso diretamente.
Por que o lxml descarta silenciosamente conteúdo profundamente aninhado?
O parser padrão impõe um limite de aninhamento de cerca de 253 níveis — uma defesa DoS do libxml2 contra documentos hostis, não um bug. Defina huge_tree=True (por exemplo, lxml.html.HTMLParser(huge_tree=True)) e ele recupera totalmente profundidades de 300 e 1.000. Observe um segundo limite de recursão, mais rígido, em torno de 2.045 níveis, que huge_tree não elimina.
O lxml libera o GIL para parsing multithread? Só nas condições certas. A FAQ do lxml afirma que o GIL é liberado durante o parsing quando cada thread usa seu próprio parser ou uma cópia do parser padrão; um parser compartilhado serializa o acesso. O ganho de 1,21x em 4 threads reaproveitado aqui reflete o caminho ingênuo com parser compartilhado, não o teto com parser por thread, que não foi medido neste estudo.
O lxml ainda é mantido em 2026? Sim. A versão estável 6.1.1 foi lançada em 2026-05-18, o repositório recebeu o último push em 2026-07-02 e há um alpha 7.0.0 em andamento. Com cerca de 3.000 estrelas no GitHub e o libxml2 sendo mantido ativamente por baixo, ele continua sendo uma biblioteca atual e bem suportada, não uma peça legada.


