Ogni pochi mesi salta fuori un parser HTML più veloce, girano i benchmark e qualcuno dice che i vecchi campioni sono già stati superati. Poi però devi selezionare ogni paragrafo che contiene una certa parola, oppure risalire al genitore di un nodo trovato, e ti torna subito in mente perché lxml è ancora aperto nell’altra scheda.
lxml è un binding di libxml2 vecchio di 20 anni. Non fa scintille. Non è nuovo. E per un compito molto specifico — tutto ciò che richiede un vero XPath — nel Python mainstream non c’è davvero niente che gli stia dietro. Questa è una recensione pratica di quello che fa, di dove vince in modo silenzioso e di quei due o tre punti in cui i suoi default possono morderti se non sai che esistono.
lxml in un paragrafo: cos’è davvero
lxml è un binding Python per le librerie C libxml2 e libxslt. È un parser e un serializer, non uno scraper e non un browser: trasforma il markup in un albero interrogabile e modificabile, e poi riconverte l’albero in byte. Offre un’API compatibile con ElementTree, un motore completo XPath 1.0, XSLT 1.0 e validazione tramite schema, ed è mantenuto da Stefan Behnel con il motto "the most feature-rich and easy-to-use library for processing XML and HTML in the Python language".
Ecco la situazione, sulla base di uno snapshot GitHub e PyPI raccolto il 2026-07-14:
| Campo | Valore |
|---|---|
| Repo | lxml/lxml |
| Star | 3.043 |
| Fork | 620 |
| Issue aperte | 16 |
| Licenza | BSD-3-Clause |
| Creato | 2011-02-11 |
| Ultimo push | 2026-07-02 |
| Stable PyPI | 6.1.1 (2026-05-18) |
| Motore incluso | libxml2 2.14.6 + libxslt 1.1.43 |
Una cosa va chiarita subito, prima che qualcuno mi accusi di esagerare: in questa recensione non c’è nessun segreto. lxml è abbastanza vecchio che ogni suo comportamento si trova da qualche parte nella documentazione lxml, in un changelog di libxml2 o in un thread su Launchpad. Non ho scoperto trucchi esclusivi e non documentati, e non me li sono inventati. Il valore di quello che segue sta nel fatto che è sistematizzato, quantificato e organizzato attorno a lxml come oggetto di studio — non nel fatto che sia una novità.
Setup del test (e perché i numeri di timing sono presi in prestito)
In questa recensione confluiscono due categorie di dati, provenienti da due posti diversi; quindi è corretto dire fin da subito quali sono e da dove arrivano.
I test di capacità — comportamento di XPath, le due API di parsing, namespace, encoding, ciclo di vita dei nodi — li ho eseguiti da zero su una macchina: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Ogni numero nei file artifacts/raw/*.json è calcolato da uno script eseguito davvero, non scritto a mano. I test di capacità sono booleani ed enum deterministici, quindi una singola esecuzione è stabile: il carico della macchina non cambia il fatto che //a/@href restituisca una stringa attributo.
I numeri di tempo e di impronta memoria non provengono da questo pacchetto. Sono riutilizzati pari pari dal precedente pacchetto di benchmark su selectolax — stessa macchina, stesso ambiente virtuale, stessa build di lxml e libxml2, benchmark aggiornati al 2026-07-13 — e qui non li ho rieseguiti. È una scelta intenzionale. Rieseguire benchmark di timing insieme a uno sciame di script di capacità rischierebbe di introdurre contesa sulla CPU e sporcare i valori riutilizzati; inoltre sarebbe un doppio lavoro: in quel pacchetto lxml era già una libreria di controllo pienamente misurata. Riutilizzare gli stessi benchmark mantiene il confronto omogeneo invece di introdurre una seconda misurazione, leggermente diversa. Quindi, quando sotto vedi un valore in millisecondi, leggilo come "stesso setup di test, aggiornato al 2026-07-13", non come "l’ho ritimato oggi".
I risultati portano un tag di confidenza: single-observation per i test deterministici di capacità, triple-run per le distribuzioni di timing riutilizzate, hypothesis quando propongo un meccanismo che non ho isolato.
XPath: l’unica cosa che selectolax e BeautifulSoup non hanno
Questo è il punto centrale, quindi parto da qui.

Ho messo xpath() di lxml davanti a una matrice pre-registrata di 37 casi — il risultato atteso per ogni prova era scritto nel codice prima dell’esecuzione, quindi non potevo correggere il voto in corsa. Dieci assi, nove stili di predicato, dieci funzioni built-in, tre tipi di ritorno scalare e cinque casi-trappola deliberati usando sintassi XPath 2.0, che il motore 1.0 di lxml dovrebbe rifiutare.
| Categoria | Copertura | Risultato |
|---|---|---|
| Assi | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 superati |
| Predicati | [1] / last() / position()<n / uguaglianza attributo / esistenza attributo / and / or / [.//a] annidato / not() | 9/9 superati |
| Funzioni | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 superate |
| Tipi di ritorno | boolean / scalari numerici | 3/3 superati |
| Casi-trappola | matches() / sequenze / if-then-else / except / errore di sintassi | 5/5 rifiutati correttamente |
Il punteggio è 37/37, e la colonna delle trappole è quella che conta davvero. matches(), le espressioni di sequenza, if/then/else ed except sono tutte sintassi XPath 2.0, e il motore 1.0 di libxml2 non le supporta “a metà”: solleva XPathEvalError e si ferma, invece di restituire in silenzio un node set sbagliato. Quindi è un punteggio perfetto dopo aver provato a romperlo, non un punteggio perfetto costruito con domande facili. Ogni comportamento qui corrisponde esattamente a quanto descrivono i documenti XPath di lxml, che è proprio il punto.
Lo ammetto: il harness ha sbagliato, ma è il tipo di “37/37” di cui ci si può davvero fidare. Il mio primo set atteso per //div[.//a[@href]] prevedeva due risultati; l’esecuzione ne ha restituito uno. Per una trentina di secondi ho pensato che lxml fosse in errore, poi ho controllato il fixture e ho scoperto che il secondo elemento era un <footer>, non un <div>: era la mia aspettativa a essere sbagliata, non il motore. Ho corretto il set atteso e lasciato l’errore in un commento del sorgente. È il giusto ordine delle colpe: prima sospetta il tuo test, poi la libreria C ventennale.
XPath vs CSS: ciò che in CSS non puoi proprio esprimere
L’idea astratta “XPath è più potente” merita un numero concreto, quindi ho quantificato il divario. lxml offre sia .xpath() sia .cssselect() (quest’ultimo traduce CSS in XPath sotto il cofano). Ho preso dieci obiettivi di selezione e verificato quali di questi sono davvero esprimibili in CSS.

| Obiettivo | XPath | CSS (cssselect) |
|---|---|---|
Filtrare per contenuto testuale (contains(text(),"bargain")) | Sì | Nessun predicato testuale |
Selezionare il genitore da un figlio (//b/parent::p) | Sì | Nessun selettore del genitore |
Restituire il valore di un attributo (//a/@href) | Sì | Solo elementi |
Restituire un nodo di testo (//p/text()) | Sì | Nessun nodo di testo |
| Asse ancestor | Sì | Nessuna navigazione verso l’alto |
Filtrare il genitore in base al numero di figli (//ul[count(li)=4]) | Sì | Nessun predicato di conteggio |
Filtrare per lunghezza del testo (string-length(text())>5) | Sì | Nessun predicato di lunghezza |
nth-child / last-child / fratello adiacente | Sì | Sì (3 casi base) |
Sette obiettivi su dieci non hanno alcuna equivalenza in CSS. Filtraggio sul testo, navigazione verso genitori e antenati, estrazione di un attributo o di un nodo di testo puro come risultato, predicati basati sul conteggio: CSS non può esprimere nulla di tutto questo. Solo tre (nth-child, last-child, fratello adiacente) funzionano in entrambi. Questa è la risposta quantificata alla domanda: “cosa guadagno davvero usando lxml?”. selectolax è solo CSS e non ha affatto un metodo xpath(), quindi quelle sette tipologie di query lì diventano loop Python in più passaggi, oppure non si fanno proprio. Se la tua logica di scraping dipende da una di queste, la scelta è già fatta.
(E sì, il harness mi ha beccato una seconda volta anche qui: avevo previsto un insieme vuoto per string-length(text())>5, ma due stringhe di sei caratteri erano davvero corrispondenti. Ho corretto l’aspettativa, non lo strumento.)
Tre livelli di rigidità: etree, recover e lxml.html
XPath è il motivo per scegliere lxml. I tre livelli di rigidità del parsing sono il motivo per continuare a tenerlo.

La maggior parte dei parser offre un solo comportamento per input rotti. lxml ne offre tre, e sono abbastanza prevedibili da farmi passare sei classi di markup malformato attraverso ognuno, con il risultato atteso pre-registrato per ogni percorso.
| Input malformato | lxml.etree (rigido) | etree + recover=True | lxml.html (tollerante) |
|---|---|---|---|
Tag non chiuso <root><a>x</root> | solleva | recupera | accetta |
Annidamento errato <b><i></b></i> | solleva | recupera | accetta |
Entità non definita | solleva | recupera | accetta |
& nudo (Tom & Jerry) | solleva | recupera | accetta |
Più radici <a>1</a><b>2</b> | solleva | recupera | accetta |
| XML ben formato | accetta | accetta (0 errori) | accetta |
Attributo booleano <input disabled> | solleva | recupera | accetta |
Tutti e sette i casi hanno corrisposto all’aspettativa pre-registrata. lxml.etree solleva XMLSyntaxError su tutte e sei le classi di malformazione. Aggiungi recover=True allo stesso parser e lui assorbe gli errori e ricostruisce un albero utilizzabile — e questa è la parte sottovalutata — parser.error_log elenca poi ogni errore che è stato inghiottito. lxml.html accetta tutto senza protestare.
Il classificatore che decide “solleva vs recupera vs accetta” è a sua volta guidato dalla lunghezza reale di error_log, non da un valore hard-coded; è per questo che un documento ben formato eseguito con recover=True viene etichettato correttamente come “accetta” (log vuoto) invece che “recupera”. La mia prima versione di quel classificatore etichettava come “recupera” qualunque risultato con recover=True, e quindi classificava male l’input pulito; leggere il vero error_log ha risolto il problema.
Cosa significa in pratica: validazione rigorosa quando un feed rotto deve fallire in modo rumoroso? Usa lxml.etree. HTML sporco del mondo reale che devi semplicemente attraversare? Usa lxml.html. E il caso intermedio che la maggior parte degli strumenti non gestisce — “sii tollerante, ma dimmi esattamente cosa era rotto così posso loggarlo” — usa recover=True e leggi l’error log. selectolax ha il livello tollerante e basta: niente modalità rigida, niente error log.
iterparse: il livello streaming che selectolax non ha affatto
Qui parliamo di capacità, non di velocità. selectolax ingerisce solo una stringa intera — non esiste un’interfaccia incrementale. iterparse di lxml emette elementi man mano che si chiudono e, se lo abbini al classico pattern fast_iter (chiama elem.clear() e rimuovi i fratelli precedenti mentre procedi), mantiene la memoria piatta anche quando il documento cresce a dismisura.

Ho misurato direttamente il comportamento in memoria — picco RSS tramite ru_maxrss, ogni soggetto in un processo nuovo, su 300.000 elementi <record> per circa 15 MB totali.
| Modalità | Delta RSS di picco | Note |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | liberato man mano; piatto indipendentemente dal numero di nodi |
iterparse senza clear | ~386 MB | mantiene i riferimenti; pesante quanto il caricamento completo |
etree.parse (caricamento completo, riferimento) | ~386 MB | noto per essere pesante; conferma che il metro legge l’ordine di grandezza |
La modalità limitata tiene il delta RSS di picco intorno a 1-2 MB contro ~386 MB del caricamento completo — una differenza di grandezza pari a circa lo 0,3-0,4% — e il primo evento record scatta prima ancora che il file abbia finito di essere letto, quindi è davvero incrementale, non uno streaming finto. La riga davvero istruttiva è quella centrale. Se esegui lo stesso ciclo iterparse ma salti clear(), la memoria torna a ~386 MB, perché stai trattenendo riferimenti a tutto. Il guadagno vive in clear(), non in iterparse da solo. Il riferimento del caricamento completo, molto più alto della modalità limitata, conferma anche che il meter RSS vede davvero il divario di scala e non sta leggendo alla cieca. (Questo test di memoria l’ho eseguito in questo pacchetto — è una misurazione di footprint, distinta dai numeri di timing presi in prestito.)
La versione reale di questo scenario: un export XML multi-gigabyte che non entra in RAM non ha alcun percorso su selectolax. Ti serve il parser streaming di lxml oppure un altro linguaggio.
Namespace: RSS, SVG e la trappola del namespace predefinito
Dodici casi di namespace, includendo RSS attraverso tre namespace, SVG con un namespace predefinito più xlink, e XML con namespace predefinito. Tutti e dodici superati.
lxml estrae //dc:creator/text() da un feed RSS e restituisce esattamente ["Alice", "Bob"], risolve //atom:link/@href e //content:encoded attraverso tre namespace distinti nello stesso documento, gestisce //s:rect e //s:use/@xlink:href nel secondo namespace di SVG, separa i nomi in notazione Clark {uri}local con QName, e consente introspezione tramite nsmap. Questo è il comportamento documentato e mantenuto, ed è una dimensione che selectolax non tocca affatto, perché selectolax è solo HTML5 e non gestisce namespace XML arbitrari.
C’è però una trappola documentata che vale la pena imparare a memoria. XPath non ha il concetto di namespace predefinito. Se appunti //book a un documento che dichiara xmlns="urn:...", non ottieni alcun risultato: il prefisso vuoto non è definito per XPath, come spiegano i documenti lxml. Devi associare un prefisso artificiale (//c:book con namespaces={"c": "urn:..."}, che ha trovato tutti e tre) oppure ripiegare su //*[local-name()='book'] (anche qui tre). Non è un bug — è lo standard XPath, implementato fedelmente. Semplicemente sorprende tutti una volta sola.
Pagine davvero sporche: fedeltà su 11 scraping reali
I test sintetici sono puliti; il web no. Ho riutilizzato undici pagine reali catturate dal set di fixture del pacchetto selectolax (aggiornate al 2026-07-10, sola lettura) e le ho passate a lxml.html, prendendo lxml come oggetto dello studio.
| Fixture | Dimensione | Link | Errori recuperati da libxml2 | XML rigido |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | solleva |
| wiki_scraping.html | 227 KB | 460 | 0 | solleva |
| gov_whitehouse.html | 289 KB | 154 | 0 | solleva |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | solleva |
| forum_reddit.html | 129 KB | 318 | 0 | solleva |
| docs_python.html | 80 KB | 341 | 2 | solleva |
| ecommerce_books.html | 51 KB | 94 | 0 | solleva |
| news_hackernews.html | 35 KB | 229 | 0 | solleva |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | solleva |
| spa_quotes_js.html | 6 KB | 5 | 0 | solleva |
Tutte e undici sono state parse con lxml.html, e i conteggi di link, heading e immagini coincidevano con i conteggi lxml riutilizzati dal pacchetto selectolax in tutti e undici i casi — cross-check true. È questa corrispondenza che mi dice che il riuso è davvero comparabile, e non due misurazioni diverse con la stessa etichetta.
Il dato collaterale: il parser XML rigido ha sollevato su dieci delle undici pagine. Le pagine web reali quasi mai sono XML ben formato, ed è proprio per questo che la modalità di recupero di libxml2 esiste: per inghiottirle. L’unica eccezione era BBC News, resa con Next.js e abbastanza ben formata da sopravvivere al parsing XML rigido. Non tutto ciò che è etichettato “HTML” ha bisogno della modalità di recupero.
Una nota sul conteggio che è facile sbagliare. Su docs_python.html, //a[@href] (esistenza dell’attributo) contava 343, mentre il if n.get("href") del pacchetto selectolax contava 341. Le due unità extra sono link con href="" vuoto. È una differenza di convenzione di conteggio — attributo presente contro attributo non vuoto — non una differenza di comportamento di lxml, e i conteggi tornano appena allinei il predicato. Utile da sapere quando fai scraping: se gli href vuoti contano oppure no è una scelta del filtro, non del parser.
Il limite di profondità che sembra un bug (ma non lo è)
Nel pacchetto selectolax era già stato osservato che lxml perdeva il contenuto più profondo in markup <div> annidati a 1.000 e 5.000 livelli, descritto come “lxml perde silenziosamente il contenuto più profondo”. Volevo capirne il meccanismo, così ho eseguito il parser di default contro huge_tree=True.

| Profondità richiesta | Profondità raggiunta dal parser di default | Profondità raggiunta con huge_tree=True |
|---|---|---|
| 300 | 253 (il resto viene perso) | 299 (recuperata) |
| 1000 | 253 (il resto viene perso) | 999 (recuperata) |
| 5000 | 253 (il resto viene perso) | 2045 (perde ancora) |
Il parser di default tronca a circa 253 livelli e scarta in silenzio tutto ciò che sta più in profondità. Non è un bug: è una difesa DoS di libxml2, un limite di annidamento di circa 256 livelli pensato per impedire che un documento ostile faccia esplodere lo stack, ed è documentato nel thread di Launchpad su lxml relativo a XML_PARSE_HUGE. Impostando huge_tree=True, le profondità 300 e 1.000 tornano completamente. A 5.000, però, anche con huge_tree attivo si arriva solo a 2.045: esiste un secondo e più rigido limite di ricorsione di libxml2, sopra quello configurabile, che huge_tree non rimuove.
Quindi l’azione pratica è semplice: quando analizzi markup molto profondo proveniente da una fonte fidata, usa lxml.html.HTMLParser(huge_tree=True). Ciò che aggiunge questo pacchetto, oltre all’osservazione riutilizzata, è il meccanismo (un limite di sicurezza, non corruzione dei dati), la correzione (huge_tree) e il fatto che esista un secondo tetto che la correzione non supera.
DOM in lettura/scrittura, serializzazione, encoding
lxml è un albero completo in lettura/scrittura, non un estrattore read-only, e ho verificato la superficie di editing caso per caso. Tutte e otto le operazioni DOM sono passate: SubElement, insert, remove, replace, strip_tags (rimuove i tag ma conserva il testo), strip_elements (rimuove tag e testo), drop_tree (esclusiva di lxml.html) e il modello a doppio slot testo/tail che confonde i principianti — in <p>head<b>bold</b>tail</p>, p.text è "head", b.text è "bold" e b.tail è "tail".
La serializzazione ha fatto cinque su cinque: tostring in modalità XML e HTML (l’HTML lascia correttamente non auto-chiusi gli elementi vuoti), pretty_print, la canonicalizzazione C14N (method="c14n", un’altra esclusiva di lxml) e un round-trip pulito.
L’encoding è il punto in cui lxml si distingue in modo quasi invisibile. Se gli passi byte non UTF-8 — "<p>café éè</p>".encode("latin-1") tramite lxml.html.fromstring — recupera café éè intatto, senza caratteri di sostituzione U+FFFD e senza byte persi. Questo replica direttamente il suo ruolo di “reference pulita” nel pacchetto selectolax, dove lo stesso input si corrompeva silenziosamente negli altri due motori (Lexbor produceva caratteri di sostituzione, Modest eliminava proprio i byte). Il rilevamento dei charset basato su libxml2 è semplicemente più stabile qui.
Il rovescio della medaglia è la rigidità nel modo in cui dichiari un encoding. encoding="latin-1" in una dichiarazione XML solleva XMLSyntaxError: Unsupported encoding: latin-1, mentre il nome canonico IANA encoding="ISO-8859-1" viene parsato correttamente e restituisce café. libxml2 accetta solo nomi di encoding canonici, non alias — un dettaglio documentato già in launchpad #613302. Fastidioso se non lo conosci, banale appena lo sai.
Infine, il ciclo di vita dei nodi. Ho eseguito tre scenari di handle obsoleti in subprocess separati (un crash duro si vedrebbe come un codice di uscita non zero): tenere un nodo dopo che il suo albero è stato raccolto dal garbage collector, leggere un handle dopo drop_tree() e usare un nodo dopo remove(). In nessuno dei tre c’è stato segfault: lxml mantiene vivo il riferimento del nodo al suo albero per evitare l’uso di memoria liberata. Stesso esito pulito ottenuto da selectolax in questo test.
Velocità e memoria (presi in prestito, e dichiarati onestamente)
Tutto in questa sezione è riutilizzato dal pacchetto selectolax, aggiornato al 2026-07-13. Questo pacchetto non produce alcun numero di timing proprio, e preferisco dirlo due volte piuttosto che farti pensare che abbia ritimato qualcosa.
| Dimensione | Valore lxml | Lettura |
|---|---|---|
| Pure parse p50 (10 MB) | 77,9 ms | circa 33-34% più veloce di selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14,18 ms / 172,9 ms | circa alla pari con Lexbor su piccole dimensioni |
| Throughput CSS su 100k nodi | 3.002.646 nodi/s | fascia più veloce dei tre motori C |
| Delta RSS su 10 MB | 128,9 MB | il più parsimonioso dei sei parser, ~1,7x più leggero di BeautifulSoup |
| Cold start import | 14,1 ms | ~2,3x più veloce degli import in stile parsel |
I numeri di pure parse e di throughput sono forti, e lxml è il più frugale in memoria tra i sei parser misurati. Il quadro threading però richiede una precisazione. I dati riutilizzati mostrano un speedup wall-clock su 4 thread di appena 1,21x, marcato come inconclusivo — ma si tratta del percorso con parser condiviso di default. Le FAQ di lxml sono esplicite: il GIL viene rilasciato durante il parsing solo quando ogni thread usa il proprio parser (o una copia del default); un parser condiviso serializza l’accesso. Ho verificato strutturalmente l’API per fare la cosa nel modo giusto (XMLParser.copy() esiste, esistono get/set_default_parser, e XPathEvaluator porta un lock interno), ma non ho misurato il guadagno con parser separati per thread — sarebbe una nuova misurazione di timing, e questo pacchetto non ne produce. Quindi leggi 1,21x come "nel percorso ingenuo con parser condiviso", non come tetto del threading in lxml.
E una nota a margine su tutto il resto: questi sono numeri di una sola piattaforma, macOS arm64. L’affermazione che il pure parse di lxml superi Lexbor va contro il consenso abituale, secondo cui il parser basato su Lexbor è il più veloce; quindi merita davvero una verifica su Linux x86_64 prima che qualcuno lo consideri definitivo.
Licenza: la vittoria noiosa
lxml è distribuito con licenza BSD-3-Clause, e le librerie C che incorpora — libxml2 e libxslt — sono entrambe MIT. È una catena completamente permissiva, senza copyleft da nessuna parte, e questo conta quando lo ridistribuisci. Per confronto, il wheel di selectolax include Modest con licenza LGPL-2.1 e Lexbor con Apache-2.0, quindi lxml è la storia più pulita se devi distribuirlo dentro un prodotto chiuso.
C’è anche un vantaggio pratico in fase di installazione: lxml pubblica wheel precompilate che linkano staticamente libxml2 e libxslt, quindi pip install lxml in genere non richiede una libxml2 di sistema né un compilatore sulla tua macchina — un’esperienza diversa dal costruirlo da sorgente.
Dove si colloca lxml — e dove entra in gioco uno strato di estrazione AI
È il momento di definire il confine, perché è facile confondere i livelli. lxml è una libreria di parsing. Ti consegna un albero e un motore di query eccellente, e tutto ciò che sta attorno a quell’albero resta comunque responsabilità tua: scaricare la pagina, renderizzare JavaScript, superare le difese anti-bot, scrivere e mantenere l’XPath, strutturare il risultato. È un livello diverso da un servizio di estrazione gestito, e i due non sono tanto rivali quanto vicini di casa.
Per uno sviluppatore che preferisce non farsi carico dello stack fetch-render-select-maintain, quello strato superiore è il posto in cui vive qualcosa come Thunderbit — e per questo pubblico contano l’API, il server MCP e la CLI, non l’estensione browser. La Thunderbit Open API espone POST /distill per trasformare una pagina in Markdown pulito e POST /extract per estrarre dati strutturati contro uno schema JSON, con un selettore renderMode e job batch per grandi volumi. Lo stesso motore è disponibile come server MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) per agenti e coding assistant, e come CLI eseguibile direttamente dal terminale via npx @thunderbit/thunderbit-cli. Gestisce rendering JS, anti-bot e CAPTCHA già pronti all’uso e restituisce JSON allineato allo schema — cioè il livello sopra il parsing, non un suo sostituto.
Prova Thunderbit per l’estrazione di dati web
La cornice è semplice. Scegli lxml quando controlli tu la pipeline e vuoi un controllo XPath chirurgico su un albero che conosci. Scegli un’API di estrazione AI quando preferisci non mantenere affatto selector e rendering. Molti sistemi reali usano entrambe: lxml per i feed strutturati che controllano, un servizio di estrazione per le pagine più sporche e di coda che non controllano.
Cosa questa recensione non ha testato
Questa è una recensione provvisoria, non una pagella definitiva, quindi ecco cosa non copre.
Tutti i numeri di timing e memoria sono riutilizzati, di una sola piattaforma (macOS arm64, Python 3.14) e si portano dietro i caveat di quel pacchetto — il risultato “lxml è più veloce nel pure parsing” va contro il consenso comune e richiede una verifica su Linux x86_64. Lo speedup threading con parser per-thread non è stato testato (servirebbe un nuovo timing). Ho misurato la memoria di iterparse su 300k record ma non su XML reali da gigabyte, non su iterparse per HTML rispetto a XML, e non su test di tenuta di diverse ore. XSLT 1.0 di lxml, la validazione RelaxNG / XMLSchema / DTD e le estensioni EXSLT non sono state testate qui: una superficie ampia, ma oltre il nucleo parsing-selezione. Ho osservato il secondo tetto di profondità a 2.045 ma non ho fissato la costante esatta di ricorsione di libxml2. È stata testata solo la stabile 6.1.1, non l’alpha 7.0.0. Windows, build da sorgente e la build 3.14t free-threaded non sono stati testati. E dentro XPath ho coperto le funzioni built-in, ma non le variabili XPath, le funzioni Python personalizzate o il riuso di oggetti etree.XPath precompilati.
Verdetto
lxml non è la novità veloce del momento, e proprio per questo è la raccomandazione giusta. È un binding libxml2 vecchio di due decenni con un motore XPath 1.0 completo che nessuna alternativa Python mainstream eguaglia, tre livelli prevedibili di rigidità del parsing con un error log nel mezzo, un vero parser streaming per documenti che non entrano in memoria, una gestione corretta di multi-namespace ed encoding, e una licenza completamente permissiva. Le poche asperità — il limite di profondità di circa 253 livelli e il numero del threading con parser condiviso — sono documentate, configurabili e ora spiegate.
Se controlli tu la pipeline di scraping e fai leva su XPath, lxml è ancora il parser a cui rivolgersi. Se invece non vuoi mantenere selector e rendering, per quello esiste uno strato di estrazione AI come Thunderbit API, MCP e CLI — una divisione pulita dei compiti, non una competizione. In ogni caso, considera questi numeri provvisori e ricontrolla i timing sulla tua piattaforma prima di citarli in un documento di design.
Prova Thunderbit per l’estrazione di dati web Get Started Free
FAQ
lxml è uno scraper web?
No. lxml è un parser e serializer — un binding Python di libxml2/libxslt che trasforma il markup in un albero editabile e interrogabile. Non scarica pagine, non renderizza JavaScript e non gestisce difese anti-bot; la parte di richiesta la fornisci tu (con requests, httpx, un browser headless o un servizio di scraping) e poi passi i byte a lxml.
Quando dovrei usare lxml invece di BeautifulSoup o selectolax? Scegli lxml quando ti serve XPath. BeautifulSoup può usare lxml come parser di backend, ma non espone XPath nativo; selectolax è solo CSS ed è più veloce nel suo nicchia ristretta. Se la tua logica di selezione richiede filtraggio per contenuto testuale, navigazione verso genitori o antenati, estrazione di attributi o nodi di testo, o predicati basati sul conteggio, il motore XPath di lxml è l’unica opzione Python mainstream che li esprime direttamente.
Perché lxml scarta in silenzio il contenuto annidato molto in profondità?
Il parser di default limita l’annidamento a circa 253 livelli — una difesa DoS di libxml2 contro documenti ostili, non un bug. Imposta huge_tree=True (per esempio lxml.html.HTMLParser(huge_tree=True)) e recupera completamente profondità di 300 e 1.000. Da notare un secondo tetto di ricorsione, più rigido, intorno ai 2.045 livelli, che huge_tree non rimuove.
lxml rilascia il GIL nel parsing multithread? Solo nelle condizioni giuste. Le FAQ di lxml dicono che il GIL viene rilasciato durante il parsing quando ogni thread usa il proprio parser o una copia del parser di default; un parser condiviso, invece, serializza l’accesso. Lo speedup riutilizzato di 1,21x su 4 thread riflette il percorso ingenuo con parser condiviso, non il limite con parser separati per thread, che qui non è stato misurato.
lxml è ancora mantenuto nel 2026? Sì. La release stabile 6.1.1 è uscita il 2026-05-18, il repository è stato pushato l’ultima volta il 2026-07-02 e c’è un alpha 7.0.0 in corso. Con circa 3.000 star su GitHub e un libxml2 attivamente mantenuto sotto il cofano, resta una libreria attuale e ben supportata, non un relitto legacy.


