A cada poucos meses aparece um parser HTML mais rápido, os benchmark circulam e alguém decreta que os veteranos ficaram para trás. Aí você precisa selecionar todos os parágrafos que contêm uma certa palavra, ou pegar o pai de um nó correspondente, e lembra por que o lxml continua aberto em outra aba.
lxml é um binding com 20 anos de idade para o libxml2. Não é empolgante. Não é novo. E, para uma tarefa específica — qualquer coisa que realmente exija XPath — nada mais no Python mainstream chega perto. Esta é uma análise prática do que ele faz, onde vence sem fazer alarde e quais são os poucos pontos em que os padrões dele podem te pegar de surpresa se você não souber que existem.
lxml em um parágrafo: o que ele realmente é
lxml é um binding Python para as bibliotecas em C libxml2 e libxslt. Ele é um parser e serializador, não um scraper nem um navegador — transforma marcação em uma árvore que você pode consultar e editar, e depois 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 por schema, 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 se encontra, com base em um snapshot do GitHub e do PyPI coletado em 2026-07-14:
| Campo | Valor |
|---|---|
| Repositório | lxml/lxml |
| Stars | 3.043 |
| Forks | 620 |
| Issues abertas | 16 |
| Licença | BSD-3-Clause |
| Criado em | 2011-02-11 |
| Último push | 2026-07-02 |
| Stable no PyPI | 6.1.1 (2026-05-18) |
| Motor incluso | libxml2 2.14.6 + libxslt 1.1.43 |
Antes que alguém me acuse de hype, vale deixar uma coisa clara: não há segredos nesta análise. O lxml é antigo o bastante para que qualquer comportamento citado aqui esteja documentado em algum lugar nos docs do lxml, em um changelog do libxml2 ou em uma thread do Launchpad. Não encontrei truque exclusivo e não documentado — e não vou inventar um. O valor do que vem a seguir está em ser sistematizado, quantificado e organizado em torno do lxml como objeto de estudo — não em trazer uma novidade bombástica.
Ambiente de teste (e por que os números de tempo são emprestados)
Duas categorias de dados entram nesta análise, e elas vêm de lugares diferentes; então vou ser transparente sobre o que é o quê.
Os testes de capacidade — comportamento de XPath, as duas APIs de parser, namespaces, codificação e ciclo de vida de nós — eu rodei do zero em uma máquina: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Todo número nos arquivos artifacts/raw/*.json foi calculado por um script executado, não digitado manualmente. Testes de capacidade são booleans e enums determinísticos, então uma única execução é estável — a carga da máquina não altera se //a/@href retorna uma string de atributo.
Os números de tempo e de uso de memória não são deste pacote. Eles foram reaproveitados verbatim do pacote anterior de benchmark do selectolax — mesma máquina, mesmo ambiente virtual, mesmo build de lxml e libxml2, benchmark até 2026-07-13 — e eu não os rodei novamente aqui. Isso é intencional. Reexecutar benchmark de tempo junto com um lote de scripts de capacidade convida contenção de CPU e contaminaria os números reaproveitados; além disso, seria trabalho duplicado: o lxml já era uma biblioteca de controle plenamente 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, até 2026-07-13", e não como "eu medi isso hoje de novo".
As conclusões trazem uma etiqueta de confiança: single-observation para os testes determinísticos de capacidade, triple-run para as distribuições de tempo reaproveitadas e hypothesis quando estou propondo um mecanismo que não isolei.
XPath: a uma coisa que selectolax e BeautifulSoup simplesmente não têm
Esse é o ponto central, então vou começar por ele.

Passei o xpath() do lxml por uma matriz pré-registrada com 37 itens — o resultado esperado para cada caso foi escrito no código antes da execução do teste, então eu não podia, sem querer, dar nota de forma indulgente. Dez axes, nove estilos de predicado, dez funções nativas, três tipos escalares de retorno e cinco casos armadilha deliberados usando sintaxe exclusiva do XPath 2.0, que o motor 1.0 do lxml deveria rejeitar.
| Categoria | Cobertura | Resultado |
|---|---|---|
| Axes | 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 / aninhado [.//a] / not() | 9/9 aprovados |
| Funções | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 aprovadas |
| Tipos de retorno | boolean / number scalars | 3/3 aprovados |
| Casos armadilha | matches() / sequences / if-then-else / except / erro de sintaxe | 5/5 rejeitados corretamente |
O resultado foi 37/37, e a coluna das armadilhas é a que realmente importa. matches(), expressões com sequences, if/then/else e except são sintaxe de XPath 2.0, e o motor 1.0 do libxml2 não oferece suporte parcial: ele levanta XPathEvalError e recusa a expressão, em vez de devolver silenciosamente um conjunto de nós errado. Ou seja, foi uma nota máxima depois de tentar quebrá-lo, não uma nota perfeita montada com perguntas fáceis. Tudo aqui se comporta exatamente como os docs de XPath do lxml descrevem — e esse é justamente o ponto.
Vou admitir uma coisa que o harness errou, porque é a versão de "37/37" em que dá para confiar de verdade. Meu primeiro conjunto esperado para //div[.//a[@href]] previa dois resultados; a execução retornou um só. Por uns trinta segundos achei que o lxml estivesse errado, depois conferi o fixture e vi que o segundo elemento era um <footer>, não um <div> — a expectativa estava errada, não o motor. 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 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() (a segunda traduz CSS para XPath por baixo dos panos). Peguei dez alvos de seleção e verifiquei quais deles CSS realmente consegue expressar.

| Alvo | XPath | CSS (cssselect) |
|---|---|---|
Filtrar por conteúdo textual (contains(text(),"bargain")) | Sim | Não há predicado de texto |
Selecionar o pai a partir do filho (//b/parent::p) | Sim | Não há seletor de pai |
Retornar um valor de atributo (//a/@href) | Sim | Apenas elementos |
Retornar um nó de texto (//p/text()) | Sim | Não há nós de texto |
Axis ancestor (//td/ancestor::div) | Sim | Não há navegação para cima |
Filtrar o pai pela contagem de filhos (//ul[count(li)=4]) | Sim | Não há predicado de contagem |
Filtrar pelo tamanho do texto (string-length(text())>5) | Sim | Não há predicado de comprimento |
nth-child / last-child / sibling adjacente | Sim | Sim (3 básicos) |
Sete dos dez alvos simplesmente 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 um nó de texto puro, predicados baseados em contagem — CSS não expressa nada disso. Só três (nth-child, last-child, sibling adjacente) funcionam nos dois. Essa é a resposta quantificada para "o que eu realmente ganho ao recorrer ao lxml". selectolax é só CSS e não tem método xpath(); então esses sete tipos de consulta viram loops Python em várias etapas ou simplesmente não acontecem. Se sua lógica de scraping depende de qualquer um deles, a decisão já foi tomada.
(E sim, o harness me pegou de novo aqui: eu previ um conjunto vazio para string-length(text())>5, mas duas strings de seis caracteres corresponderam. 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 rigor em três velocidades é o motivo para continuar usando.

A maioria dos parsers te dá um único comportamento para entrada quebrada. O 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 |
& solto (Tom & Jerry) | lança erro | recupera | aceita |
Várias 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 bateram com a expectativa pré-registrada. lxml.etree levanta 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 — o parser.error_log então lista todos os erros engolidos. lxml.html aceita tudo sem reclamar.
O classificador que decide "lançou erro vs recuperou vs aceitou" é guiado pelo comprimento real do error_log em tempo de execução, e não por valores codificados, por isso um documento bem formado executado com recover=True é corretamente classificado como "aceita" (log vazio), e não como "recupera". Minha primeira versão desse classificador rotulava qualquer resultado com recover=True como "recupera" e classificava errado a entrada limpa; olhar o error_log de verdade corrigiu isso.
O que isso entrega na prática: validação estrita quando um feed quebrado deve falhar de forma explícita, use lxml.etree. HTML sujo do mundo real que você só precisa conseguir ler, use lxml.html. E o caso intermediário que a maioria das ferramentas não faz — "seja tolerante, mas me diga exatamente o que estava quebrado para eu registrar" — use recover=True e leia o log de erros. selectolax tem a opção tolerante e mais nada: sem modo estrito e sem log de erros.
iterparse: o modo streaming que selectolax não tem de forma alguma
Aqui estamos falando de capacidade, não de velocidade. selectolax só ingere uma string inteira — não há interface incremental. O iterparse do lxml emite elementos conforme eles se fecham e, combinado com o padrão clássico fast_iter (chame elem.clear() e remova irmãos anteriores à medida que avança), mantém a memória praticamente plana, não importa o tamanho do documento.

Medi diretamente o comportamento de memória — pico de RSS via ru_maxrss, cada caso em um processo novo, em 300.000 elementos <record> totalizando cerca de 26,7 MB (26.744.801 bytes).
| Modo | Delta de RSS no pico | Observações |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | liberando aos poucos; plano независимо do volume |
iterparse sem clear | ~386 MB | mantém referências; tão pesado quanto carregar tudo |
etree.parse (load completo, referência) | ~386 MB | pesado por definição; prova que o medidor lê magnitude |
O modo limitado mantém o pico de RSS em algo como 1-2 MB contra ~386 MB do carregamento completo — uma diferença de magnitude de 0,3-0,4% — e o primeiro evento de record aparece antes mesmo de o arquivo terminar de ser lido, então isso é streaming de verdade, não uma simulação. A linha mais instrutiva é a do meio. Rode o mesmo loop iterparse, mas pule clear(), e a memória volta para ~386 MB, porque você continua segurando referências a tudo. O ganho está no clear(), não no iterparse por si só. A leitura de referência em carga total, muito mais alta que o modo limitado, também confirma que o medidor de RSS consegue enxergar a diferença de magnitude, e não está lendo no escuro. (Esse teste de memória eu rodei neste pacote — é uma medição de footprint, distinta dos números de tempo reaproveitados.)
Na prática, a versão real disso é simples: um export XML de vários gigabytes que não cabe na RAM não tem caminho no selectolax. É o parser 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.
O lxml extrai //dc:creator/text() de um feed RSS como exatamente ["Alice", "Bob"], resolve //atom:link/@href e //content:encoded em três namespaces separados no mesmo documento, lida com //s:rect e //s:use/@xlink:href no segundo namespace do SVG, separa nomes Clark-notation {uri}local com QName e introspecta via nsmap. Esse é o comportamento documentado e mantido, e é uma dimensão inteira que o selectolax não toca, porque o selectolax é apenas HTML5 e não processa namespaces XML arbitrários.
Há uma armadilha documentada que vale guardar na memória. XPath não tem o conceito de namespace padrão. Aponte //book para um documento que declara xmlns="urn:..." e você terá zero resultados — o prefixo vazio é indefinido para XPath, como os docs do lxml explicam. Você precisa vincular um prefixo artificial (//c:book com namespaces={"c": "urn:..."}, que encontrou os três) ou recorrer a //*[local-name()='book'] (também três). Não é bug — é o XPath funcionando exatamente como especificado. Só surpreende todo mundo uma vez.
Páginas sujas do mundo real: fidelidade em 11 scrapes reais
Testes sintéticos são limpos; a web não é. Reaproveitei onze páginas reais capturadas do conjunto de fixtures do pacote selectolax (até 2026-07-10, somente leitura) e passei lxml.html por elas, com o lxml como sujeito.
| 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 parseados com lxml.html, e as contagens de links, títulos e imagens bateram com as contagens reaproveitadas do lxml no pacote selectolax em todos os onze — verificação cruzada true. Essa concordância me diz que a reutilização está de fato no mesmo terreno e não são duas medições diferentes com o mesmo rótulo.
A descoberta lateral: o parser XML estrito lançou erro em dez das onze páginas. Páginas reais da web quase nunca são XML bem formado, e é exatamente por isso que o modo de recuperação HTML do libxml2 existe: para engoli-las. A única exceção foi o BBC News, renderizado com Next.js e bem formado o bastante para passar no XML estrito. Nem tudo o que é chamado de "HTML" precisa de recuperação.
Um detalhe de contagem em que é fácil tropeçar. Em docs_python.html, //a[@href] (existência de atributo) contou 343, enquanto o pacote selectolax, com if n.get("href") (valor verdadeiro), contou 341. Os dois extras são links com href="" vazio. Isso é diferença de convenção de contagem — atributo existe versus atributo não vazio —, não diferença de comportamento do lxml, e as contagens se reconciliam quando você alinha o predicado. Vale saber ao fazer scraping: se href vazio conta ou não é escolha do 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 em 1.000 e 5.000 níveis, e enquadrou isso como "o lxml perde silenciosamente o conteúdo mais profundo". Eu queria entender o mecanismo, então rodei o parser padrão com 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 corta em cerca de 253 níveis e descarta silenciosamente o que vier depois. Isso não é bug — é a defesa DoS do libxml2, um limite de aninhamento de aproximadamente 256 níveis que impede um documento hostil de estourar a pilha, e isso está documentado no thread do Launchpad do lxml sobre XML_PARSE_HUGE. Defina huge_tree=True e profundidades de 300 e 1.000 voltam completas. Já profundidade 5.000 só chega a 2.045 mesmo com huge_tree ativado — existe um segundo teto de recursão do libxml2, mais duro, acima do configurável, e huge_tree não o remove.
Então a ação prática é concreta: ao parsear marcação profunda de uma fonte confiável, use lxml.html.HTMLParser(huge_tree=True). O que este pacote acrescenta em cima da 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 que há um segundo teto que a correção não alcança.
DOM de leitura e escrita, serialização e codificação
lxml é uma árvore completa de leitura e escrita, não um extrator só de 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, mas preserva o texto), strip_elements (remove tags e o texto delas), drop_tree (exclusivo de lxml.html) e o modelo de dois slots texto/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 cinco de cinco: tostring nos modos XML e HTML (o HTML corretamente não fecha elementos void com self-close), pretty_print, canonicalização C14N (method="c14n", outra exclusividade do lxml) e um round-trip limpo.
Codificação é onde o lxml se diferencia discretamente. Dê a ele bytes fora de 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 foi corrompida silenciosamente nos outros dois motores (Lexbor produziu caracteres de substituição, Modest descartou bytes). A detecção de charset baseada em libxml2 é simplesmente mais estável aqui.
O outro lado é o rigor em relação a como você declara a codificação. encoding="latin-1" em uma declaração XML gera XMLSyntaxError: Unsupported encoding: latin-1, enquanto o nome canônico IANA encoding="ISO-8859-1" faz parse corretamente e retorna café. O libxml2 só aceita nomes canônicos de encoding, não aliases — um detalhe documentado lá atrás na launchpad #613302. Chato se você não souber; trivial quando sabe.
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 não zero): manter um nó depois que a á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 a referência do nó para sua árvore viva, evitando use-after-free. Mesmo bom resultado que o selectolax obteve neste teste.
Velocidade e memória (emprestadas, e honestamente assumidas como tais)
Tudo nesta seção foi reaproveitado do pacote selectolax, até 2026-07-13. Este pacote não produziu nenhum número de tempo próprio, e eu prefiro dizer isso duas vezes do que fazer você achar que reexecutei algo.
| Dimensão | Valor do lxml | Leitura |
|---|---|---|
| Pure parse p50 (10 MB) | 77,9 ms | ~33-34% mais rápido que selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14,18 ms / 172,9 ms | praticamente empatado com Lexbor em tamanhos pequenos |
| Throughput CSS em 100k nós | 3.002.646 nós/s | faixa mais rápida entre os três motores em C |
| Delta de RSS em 10 MB | 128,9 MB | o mais enxuto dos seis parsers, ~1,7x mais leve que BeautifulSoup |
| Cold start de importação | 14,1 ms | ~2,3x mais rápido que imports no estilo parsel |
Os números de pure parse e throughput são fortes, e o lxml é o parser mais econômico em memória entre os seis medidos. Mas o quadro de threads precisa de uma ressalva. Os dados reaproveitados mostram um speedup em wall-clock de apenas 1,21x com 4 threads, marcado como inconclusivo — só que isso é o caminho com parser padrão compartilhado. O FAQ do lxml é explícito ao dizer que o GIL é liberado durante o parsing apenas quando cada thread usa seu próprio parser (ou uma cópia do padrão); um parser compartilhado serializa o acesso. Eu verifiquei estruturalmente a superfície de API para fazer do jeito certo (XMLParser.copy() existe, get/set_default_parser existem, XPathEvaluator carrega um lock interno), mas não medi o ganho com parser por thread — isso exigiria nova medição de tempo, e este pacote não produz esse tipo de dado. Então leia "1,21x" como "no caminho ingênuo com parser compartilhado", e não como limite de threading do lxml.
E há um asterisco em tudo isso: são números de uma única plataforma, macOS arm64. A afirmação de que o pure parse do lxml bate o Lexbor vai contra o consenso usual de que o parser baseado em Lexbor é o mais rápido, então isso realmente pede uma rechecagem em Linux x86_64 antes que alguém trate o resultado como definitivo.
Licença: a vitória sem glamour
lxml é distribuído sob BSD-3-Clause, e as bibliotecas em C que ele embute — libxml2 e libxslt — usam MIT. Isso forma uma cadeia totalmente permissiva, sem copyleft em nenhum ponto, o que importa assim que você redistribui. Em contraste, o wheel do selectolax embute Modest sob LGPL-2.1 e Lexbor sob Apache-2.0, então lxml conta uma história mais simples para embarcar em um produto fechado.
Há também uma vantagem prática na instalação: o lxml publica wheels pré-compilados que linkam estaticamente libxml2 e libxslt, então pip install lxml normalmente não precisa de libxml2 do sistema nem de 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 por IA assume
É hora de deixar claro o limite, porque é fácil confundir as categorias. lxml é uma biblioteca de parsing. Ele te entrega uma árvore e um mecanismo de consulta excelente, e todo o resto ao redor dessa árvore ainda é responsabilidade sua: buscar a página, renderizar JavaScript, contornar proteções anti-bot, escrever e manter o XPath e estruturar o resultado. Isso é uma camada diferente de um serviço hospedado de extração, e os dois não são exatamente rivais — são mais vizinhos.
Para quem prefere não assumir sozinho a pilha de fetch-render-select-maintain, essa camada superior é onde algo como Thunderbit entra — e, para este público, não é a extensão do navegador, e sim a API, o servidor MCP e o CLI. 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 volumes maiores. O mesmo motor 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 executável diretamente no terminal via npx @thunderbit/thunderbit-cli. Ele lida com renderização JS, anti-bot e CAPTCHAs por padrão e devolve JSON compatível com o schema — ou seja, é a camada acima do parsing, não um substituto para ele.
Experimente Thunderbit para extração de dados da web
O enquadramento é simples. Use lxml quando você controla o pipeline e quer controle cirúrgico de XPath sobre uma árvore que entende. Use uma API de extração por IA quando preferir não manter seletores e renderização. Muitos sistemas reais usam os dois: lxml para os feeds estruturados que controlam, um serviço de extração para as páginas long tail bagunçadas 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 os alertas desse pacote — o resultado de que o lxml é mais rápido em pure parsing vai contra o consenso e precisa de nova checagem em Linux x86_64. O speedup de threading com parser por thread não foi testado (exigiria nova medição de tempo). Medi a memória do iterparse em 300 mil registros, mas não em XML real de escala de gigabytes, nem iterparse em HTML versus XML, nem um teste de longa duração. 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 de capacidade grande, 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. Só a versão estável 6.1.1 foi testada, não o alpha 7.0.0. Windows, builds a partir do código-fonte e a build free-threaded 3.14t ficaram todos fora do escopo. E, dentro do próprio XPath, eu cobri funções nativas, mas não variáveis XPath, funções de extensão Python personalizadas ou reuso de objetos etree.XPath pré-compilados.
Veredito
lxml não é a novidade mais rápida do momento — e isso é exatamente o motivo da recomendação. É um binding do libxml2 com duas décadas de estrada, com um motor XPath 1.0 completo que nenhuma alternativa mainstream em Python iguala, três níveis previsíveis de rigor de parsing com um log de erros no meio, um parser streaming real para documentos que não cabem na memória, tratamento correto de múltiplos namespaces e de codificação, e uma licença totalmente permissiva. Os poucos pontos mais sensíveis — o limite de profundidade em ~253 níveis e o número de threads com parser compartilhado — são documentados, configuráveis e agora explicados.
Se você controla seu pipeline de scraping e depende de XPath, lxml ainda é o parser para o qual você deve ir. Se preferir não manter seletores nem renderização, é para isso que existe uma camada de extração por IA como a API, MCP e CLI do Thunderbit — uma divisão clara de trabalho, não uma competição. De qualquer forma, trate estes números como provisórios e revalide o tempo na sua própria plataforma antes de citá-los em um documento de arquitetura.
Experimente Thunderbit para extração de dados da web Get Started Free
FAQs
O lxml é um web scraper?
Não. lxml é um parser e serializador — um binding 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 ao lxml.
Quando devo usar lxml em vez de BeautifulSoup ou selectolax? Use lxml quando precisar de XPath. O BeautifulSoup até pode usar lxml como parser de backend, mas não expõe XPath nativo; o selectolax é só CSS e mais rápido no seu nicho estreito. Se sua lógica de seleção precisa de filtragem por conteúdo textual, navegação para pai ou ancestral, extração de atributo/nó de texto ou 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 muito profundamente aninhado?
O parser padrão limita o aninhamento em 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 completamente profundidades de 300 e 1.000. Observe que existe um segundo teto de recursão, mais rígido, por volta de 2.045 níveis, que huge_tree não remove.
O lxml libera o GIL no parsing multithread? Só sob as condições certas. O 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, em vez disso, serializa o acesso. O speedup reaproveitado de 4 threads, de 1,21x, reflete o caminho ingênuo com parser compartilhado, não o limite com parser por thread, que não foi medido aqui.
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 stars no GitHub e um libxml2 mantido ativamente por baixo, ele continua sendo uma biblioteca atual e bem suportada, não uma peça legada.


