Co kilka miesięcy pojawia się szybszy parser HTML, lecą benchmarki i ktoś ogłasza, że stara gwardia jest już niepotrzebna. A potem chcesz wybrać każdy akapit, który zawiera konkretne słowo, albo pobrać element nadrzędny pasującego węzła, i nagle przypominasz sobie, dlaczego lxml nadal masz otwarte w drugiej karcie.
lxml to 20-letni binding do libxml2. Nie jest efektowny. Nie jest nowy. A mimo to w jednej konkretnej dziedzinie — wszędzie tam, gdzie potrzebny jest prawdziwy XPath — żaden inny mainstreamowy pakiet Pythona realnie nie konkuruje. To praktyczny test tego, co lxml robi dobrze, gdzie po cichu wygrywa i w których miejscach jego domyślne ustawienia mogą cię ugryźć, jeśli nie wiesz, że tam są.
lxml w jednym akapicie: czym on właściwie jest
lxml to binding Pythona do bibliotek C libxml2 i libxslt. To parser i serializer, a nie scraper czy przeglądarka — zamienia markup w drzewo, które można przeszukiwać i edytować, a potem z powrotem serializuje je do bajtów. Oferuje API zgodne z ElementTree, pełny silnik XPath 1.0, XSLT 1.0 oraz walidację schematów. Projekt utrzymuje Stefan Behnel pod hasłem "the most feature-rich and easy-to-use library for processing XML and HTML in the Python language".
Tak wygląda jego stan na podstawie zrzutu z GitHuba i PyPI pobranego 2026-07-14:
| Pole | Wartość |
|---|---|
| Repozytorium | lxml/lxml |
| Gwiazdki | 3,043 |
| Forki | 620 |
| Otwarte zgłoszenia | 16 |
| Licencja | BSD-3-Clause |
| Utworzono | 2011-02-11 |
| Ostatni push | 2026-07-02 |
| Stabilne PyPI | 6.1.1 (2026-05-18) |
| Dołączony silnik | libxml2 2.14.6 + libxslt 1.1.43 |
Zanim ktoś oskarży mnie o marketingowy entuzjazm, od razu to wyjaśnię: w tej recenzji nie ma żadnych sekretów. lxml jest na tyle stary, że każde zachowanie opisano już gdzieś w dokumentacji lxml, changelogu libxml2 albo w wątku na Launchpadzie. Nie odkryłem żadnego nieudokumentowanego triku i nie zamierzam niczego wymyślać. Wartość tego materiału polega na tym, że porządkuje fakty, nadaje im ramy liczbowe i ustawia lxml jako główny temat — a nie na tym, że ujawnia nowości.
Jak wyglądało środowisko testowe (i dlaczego liczby czasu są zapożyczone)
Do tej recenzji trafiły dwie kategorie danych i pochodzą z dwóch różnych miejsc, więc od razu zaznaczam które z których.
Testy możliwości — zachowanie XPath, dwa interfejsy parsera, namespace’y, kodowanie, cykl życia węzłów — uruchomiłem od zera na jednej maszynie: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Każda liczba w plikach artifacts/raw/*.json została policzona przez skrypt, a nie wpisana ręcznie. Testy możliwości są deterministyczne, więc pojedyncze uruchomienie jest stabilne — obciążenie maszyny nie zmieni tego, czy //a/@href zwróci wartość atrybutu.
Liczby czasu i zużycia pamięci nie pochodzą z tego pakietu. Zostały wzięte wprost z wcześniejszego pakietu benchmarków selectolax — ta sama maszyna, to samo środowisko wirtualne, ten sam build lxml i libxml2, benchmarki z dnia 2026-07-13 — i nie uruchamiałem ich ponownie tutaj. Zrobiłem to celowo. Odpalanie benchmarków czasowych równolegle z pakietem testów możliwości wprowadzałoby kontencję CPU, która zanieczyściłaby użyte ponownie wyniki, a poza tym byłaby to podwójna robota: lxml już wcześniej pełnił tam rolę w pełni zmierzonej biblioteki kontrolnej. Ponowne użycie tych samych pomiarów pozwala zachować porównanie jabłko do jabłka zamiast wprowadzać drugi, subtelnie inny zestaw danych. Jeśli więc poniżej zobaczysz liczbę w milisekundach, czytaj ją jako „to samo stanowisko testowe, stan na 2026-07-13”, a nie „dziś to mierzyłem od nowa”.
Wynikom przypisuję etykiety pewności: single-observation dla deterministycznych testów możliwości, triple-run dla ponownie użytych rozkładów czasowych i hypothesis tam, gdzie proponuję mechanizm, którego nie izolowałem.
XPath: jedyna rzecz, której selectolax i BeautifulSoup po prostu nie mają
To jest główny argument, więc zacznę od niego.

Przepuściłem xpath() w lxml przez wcześniej zarejestrowaną matrycę 37 przypadków — oczekiwany wynik dla każdego z nich był wpisany do źródła jeszcze przed startem testu, więc nie mogłem „dać sobie forów”. Dziesięć osi, dziewięć stylów predykatów, dziesięć wbudowanych funkcji, trzy typy zwracanych skalarów i pięć celowych pułapek z użyciem składni tylko z XPath 2.0, którą silnik 1.0 w lxml powinien odrzucić.
| Kategoria | Zakres | Wynik |
|---|---|---|
| Osie | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predykaty | [1] / last() / position()<n / równość atrybutów / istnienie atrybutu / and / or / zagnieżdżone [.//a] / not() | 9/9 pass |
| Funkcje | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 pass |
| Typy zwracane | boolean / number scalars | 3/3 pass |
| Przypadki-pułapki | matches() / sekwencje / if-then-else / except / błąd składni | 5/5 correctly rejected |
Wynik to 37/37, a kolumna z pułapkami jest tu najważniejsza. matches(), wyrażenia sekwencyjne, if/then/else i except to składnia XPath 2.0, a silnik libxml2 1.0 nie udaje, że ją obsługuje — zwraca XPathEvalError i odmawia wykonania zamiast po cichu zwrócić zły zestaw węzłów. To jest więc wynik idealny po próbie złamania parsera, a nie idealny wynik złożony z łatwych pytań. Każde zachowanie tutaj dokładnie zgadza się z tym, co opisuje dokumentacja XPath w lxml — i o to właśnie chodzi.
Przyznam jeszcze jedną rzecz: harness miał na początku błąd, i to jest wersja „37/37”, której można naprawdę ufać. Mój pierwszy oczekiwany zestaw dla //div[.//a[@href]] przewidywał dwa trafienia; test zwrócił jedno. Przez około 30 sekund byłem przekonany, że lxml się myli, ale potem sprawdziłem fixture i okazało się, że drugi element był <footer>, a nie <div> — błąd był po mojej stronie, nie po stronie silnika. Poprawiłem oczekiwany wynik i zostawiłem komentarz w źródle. To właściwa kolejność podejrzanych: najpierw test, dopiero potem 20-letnią bibliotekę C.
XPath kontra CSS: czego dosłownie nie da się wyrazić w CSS
Abstrakcyjne stwierdzenie „XPath jest mocniejszy” warto poprzeć liczbą, więc policzyłem tę różnicę. lxml daje zarówno .xpath(), jak i .cssselect() (to drugie tłumaczy CSS na XPath pod spodem). Wziąłem dziesięć celów selekcji i sprawdziłem, które z nich da się w ogóle wyrazić w CSS.

| Cel | XPath | CSS (cssselect) |
|---|---|---|
Filtrowanie po treści tekstowej (contains(text(),"bargain")) | Tak | Brak predykatu tekstowego |
Wybór rodzica z dziecka (//b/parent::p) | Tak | Brak selektora rodzica |
Zwrócenie wartości atrybutu (//a/@href) | Tak | Tylko elementy |
Zwrócenie węzła tekstowego (//p/text()) | Tak | Brak węzłów tekstowych |
Oś ancestor (//td/ancestor::div) | Tak | Brak nawigacji w górę |
Filtrowanie rodzica po liczbie dzieci (//ul[count(li)=4]) | Tak | Brak predykatu zliczającego |
Filtrowanie po długości tekstu (string-length(text())>5) | Tak | Brak predykatu długości |
nth-child / last-child / adjacent sibling | Tak | Tak (3 baseline) |
Siedem z dziesięciu celów nie ma żadnego odpowiednika w CSS. Filtrowanie po treści tekstu, przechodzenie w górę do rodziców i przodków, zwracanie wartości atrybutu albo surowego węzła tekstowego, predykaty oparte na liczbie dzieci — CSS po prostu nie umie tego wyrazić. Tylko trzy przypadki (nth-child, last-child, sąsiadujący element) działają w obu. To jest policzona odpowiedź na pytanie „co realnie zyskuję, sięgając po lxml”. selectolax działa wyłącznie w CSS i w ogóle nie ma metody xpath(), więc tam te siedem typów zapytań albo trzeba rozbić na wieloetapowe pętle Pythona, albo w ogóle nie da się ich zrobić. Jeśli twoja logika scrapowania na nich polega, decyzja została już podjęta.
(I tak, harness złapał mnie tu drugi raz: przewidziałem pusty wynik dla string-length(text())>5, a tymczasem dopasowały się dwa sześcioznakowe ciągi. Poprawiłem oczekiwanie, nie narzędzie.)
Trzy poziomy rygoru: etree, recover i lxml.html
XPath jest powodem, dla którego warto wybrać lxml. Trzy tryby rygoru są powodem, żeby go potem dalej używać.

Większość parserów daje jedno zachowanie dla uszkodzonych danych. lxml daje trzy i są na tyle przewidywalne, że przepuściłem przez nie sześć klas wadliwego markup’u, wcześniej rejestrując oczekiwane zachowanie każdego wariantu.
| Wadliwy input | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Niedomknięty tag <root><a>x</root> | rzuca wyjątek | odzyskuje | akceptuje |
Źle zagnieżdżone <b><i></b></i> | rzuca wyjątek | odzyskuje | akceptuje |
Niezdefiniowana encja | rzuca wyjątek | odzyskuje | akceptuje |
Samo & (Tom & Jerry) | rzuca wyjątek | odzyskuje | akceptuje |
Wiele korzeni <a>1</a><b>2</b> | rzuca wyjątek | odzyskuje | akceptuje |
| Poprawny XML | akceptuje | akceptuje (0 błędów) | akceptuje |
Atrybut boolean <input disabled> | rzuca wyjątek | odzyskuje | akceptuje |
Siedem na siedem przypadków zgadza się z wcześniej zarejestrowanym oczekiwaniem. lxml.etree zgłasza XMLSyntaxError dla wszystkich sześciu klas błędów. Dodaj recover=True do tego samego parsera, a on połyka błędy i odbudowuje użyteczne drzewo — i tu jest niedoceniana rzecz — parser.error_log potem wypisuje każdy z połkniętych błędów. lxml.html akceptuje wszystko bez narzekania.
Klasyfikator, który decyduje, czy jest „rzucił wyjątek”, „odzyskał” czy „akceptuje”, opiera się na rzeczywistej długości error_log, a nie na hardkodzie, dlatego poprawny dokument przetwarzany z recover=True zostaje poprawnie oznaczony jako „akceptuje” (pusty log), a nie „odzyskuje”. Pierwsza wersja klasyfikatora przypisywała każdemu wynikowi z recover=True etykietę „odzyskuje” i błędnie oznaczała czysty input; sprawdzenie faktycznego error_log to naprawiło.
Co to daje w praktyce: jeśli chcesz twardej walidacji, bo uszkodzony feed ma się wyłożyć głośno, użyj lxml.etree. Jeśli chcesz przełknąć brudny HTML z internetu, użyj lxml.html. A w środku, tam gdzie większość narzędzi się poddaje — „bądź łagodny, ale pokaż mi dokładnie, co było zepsute, żebym mógł to zalogować” — użyj recover=True i odczytaj log błędów. selectolax ma tylko tryb łagodny i nic poza tym: bez trybu ścisłego i bez error logu.
iterparse: tryb strumieniowy, którego selectolax w ogóle nie ma
To nie jest parametr szybkości, tylko sama możliwość. selectolax wczytuje tylko cały string — nie ma interfejsu inkrementalnego. iterparse w lxml zwraca elementy w momencie zamknięcia, a w połączeniu z klasycznym wzorcem fast_iter (wywołanie elem.clear() i usuwanie poprzednich rodzeństw w locie) trzyma zużycie pamięci płasko, niezależnie od tego, jak duży jest dokument.

Zmierzylem charakterystykę pamięci bezpośrednio — szczytowy RSS przez ru_maxrss, każdy wariant w osobnym świeżym procesie, na 300,000 elementów <record> o łącznym rozmiarze około 15 MB.
| Tryb | Szczytowy przyrost RSS | Uwagi |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | zwalnia po drodze; płasko niezależnie od liczby rekordów |
iterparse bez clear | ~386 MB | trzyma referencje; ciężkie jak pełne wczytanie |
etree.parse (pełne wczytanie, punkt odniesienia) | ~386 MB | celowo ciężkie; pokazuje, że licznik widzi skalę |
Tryb ograniczony utrzymuje szczytowy przyrost RSS na poziomie około 1-2 MB wobec ~386 MB przy pełnym wczytaniu — różnica rzędu 0.3-0.4% — a pierwszy event rekordu pojawia się jeszcze zanim plik skończy się czytać, więc to naprawdę jest streaming inkrementalny, a nie udawany. Najbardziej pouczająca jest środkowa linia. Uruchom ten sam loop iterparse, ale pomiń clear(), a pamięć znów rośnie do ~386 MB, bo trzymasz referencje do wszystkiego. Zysk siedzi w clear(), nie w samym iterparse. Linia odniesienia przy pełnym wczytaniu pokazuje też, że licznik RSS faktycznie widzi tę różnicę skali, a nie działa po omacku. (Ten test pamięci robiłem w tym pakiecie — to pomiar footprintu, odrębny od zapożyczonych liczb czasowych.)
W praktyce oznacza to tyle: wielogigabajtowy eksport XML, który nie mieści się w RAM, nie ma żadnej ścieżki w selectolax. Zostaje strumieniowy parser lxml albo inny język.
Namespace’y: RSS, SVG i pułapka domyślnego namespace’u
Dwanaście przypadków namespace’ów, obejmujących RSS w trzech przestrzeniach nazw, SVG z domyślnym namespace’em i xlink oraz XML z domyślnym namespace’em. Wszystkie dwanaście przeszło.
lxml wyciąga //dc:creator/text() z feeda RSS dokładnie jako ["Alice", "Bob"], rozwiązuje //atom:link/@href i //content:encoded w trzech osobnych przestrzeniach nazw w tym samym dokumencie, obsługuje //s:rect i //s:use/@xlink:href w drugiej przestrzeni nazw SVG, rozbija nazwy w notacji Clarka {uri}local przez QName i pozwala je introspekcyjnie odczytać przez nsmap. To zachowanie jest utrzymywane i opisane w dokumentacji, i to jest cały wymiar, którego selectolax w ogóle nie dotyka, bo selectolax jest tylko HTML5 i nie przetwarza dowolnych namespace’ów XML.
Jest jednak jedna dobrze udokumentowana pułapka, którą warto zapamiętać. XPath nie zna pojęcia domyślnego namespace’u. Jeśli skierujesz //book na dokument z xmlns="urn:...", dostaniesz zero trafień — pusty prefiks nie istnieje w XPath, jak jasno opisuje dokumentacja lxml. Trzeba podpiąć sztuczny prefiks (//c:book z namespaces={"c": "urn:..."}, co znalazło wszystkie trzy) albo przejść na //*[local-name()='book'] (też trzy). To nie bug — to specyfikacja XPath, wiernie zaimplementowana. Po prostu każdy daje się na to nabrać dokładnie raz.
Prawdziwie brudne strony: zgodność na 11 rzeczywistych zrzutach
Testy syntetyczne są czyste; sieć już nie. Ponownie użyłem jedenastu realnych przechwyconych stron z zestawu fixture’ów z pakietu selectolax (stan na 2026-07-10, tylko do odczytu) i przepuściłem je przez lxml.html, traktując lxml jako temat badania.
| Fixture | Rozmiar | Linki | Błędy odzyskane przez libxml2 | Strict XML |
|---|---|---|---|---|
| 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 |
Wszystkie jedenaście stron sparsowało się przez lxml.html, a liczby linków, nagłówków i obrazków zgadzały się ze wcześniej użytymi liczbami lxml z pakietu selectolax we wszystkich jedenastu przypadkach — cross-check: true. To właśnie ta zgodność mówi mi, że ponowne użycie danych jest naprawdę porównaniem jabłko do jabłka, a nie dwóch różnych pomiarów opisanych tą samą etykietą.
Wniosek poboczny: ścisły parser XML rzucił wyjątek na dziesięciu z jedenastu stron. Prawdziwe strony WWW w ogromnej większości nie są poprawnym XML-em, i dokładnie dlatego tryb odzyskiwania w libxml2 istnieje — żeby je przełknąć. Jedynym wyjątkiem był BBC News, renderowany przez Next.js i na tyle poprawny, że przeszedł ścisły XML. Nie wszystko, co ma etykietę „HTML”, wymaga trybu naprawczego.
Jedna uwaga o liczeniu, na którą łatwo się nadziać. Na docs_python.html //a[@href] (istnienie atrybutu) policzyło 343, podczas gdy w pakiecie selectolax if n.get("href") (wartość prawdziwa) dało 341. Te dwa dodatkowe przypadki to puste linki href="". To różnica w konwencji liczenia — atrybut istnieje kontra atrybut nie jest pusty — a nie różnica w zachowaniu lxml; wyniki zgadzają się, gdy wyrównasz predykat. Warto to pamiętać podczas scrapowania: to, czy puste href-y mają się liczyć, zależy od twojego filtra, nie od parsera.
Limit głębokości, który wygląda jak bug, ale nim nie jest
W pakiecie selectolax odnotowano, że lxml gubi najgłębszą zawartość w markupu zagnieżdżonym na 1,000 i 5,000 poziomach <div>, i opisano to jako „lxml po cichu traci najgłębszą treść”. Chciałem zobaczyć mechanizm, więc porównałem parser domyślny z huge_tree=True.

| Żądana głębokość | Parser domyślny sięga | huge_tree=True sięga |
|---|---|---|
| 300 | 253 (reszta znika) | 299 (odzyskane) |
| 1000 | 253 (reszta znika) | 999 (odzyskane) |
| 5000 | 253 (reszta znika) | 2045 (nadal znika część) |
Domyślny parser ucina mniej więcej na 253 poziomach i po prostu pomija wszystko głębiej. To nie jest bug — to mechanizm obrony libxml2 przed DoS, mniej więcej 256-poziomowy limit zagnieżdżenia, który chroni przed dokumentem próbującym rozsadzić stos. Jest to opisane w wątku Launchpada o XML_PARSE_HUGE. Ustaw huge_tree=True, a głębokości 300 i 1,000 wracają w całości. Głębokość 5,000 jednak sięga tylko 2,045 nawet przy włączonym huge_tree — istnieje drugi, twardszy limit rekurencji libxml2 ponad tym konfigurowalnym i huge_tree go nie usuwa.
Wniosek praktyczny jest więc prosty: jeśli parsujesz głęboki markup ze zaufanego źródła, użyj lxml.html.HTMLParser(huge_tree=True). To, co ten pakiet dodaje ponad zapożyczoną obserwację, to mechanizm (limit bezpieczeństwa, nie korupcja danych), rozwiązanie (huge_tree) i fakt, że istnieje drugi sufit, którego to rozwiązanie nie przebija.
DOM do odczytu i zapisu, serializacja, kodowanie
lxml to pełne drzewo do odczytu i zapisu, a nie tylko ekstraktor do czytania, więc sprawdziłem warstwę edycji przypadek po przypadku. Wszystkie osiem operacji DOM przeszło: SubElement, insert, remove, replace, strip_tags (usuwa tagi, zostawia tekst), strip_elements (usuwa tagi i tekst), drop_tree (funkcja dostępna wyłącznie w lxml.html) oraz model dwóch slotów text/tail, który zaskakuje nowych użytkowników — w <p>head<b>bold</b>tail</p> p.text to "head", b.text to "bold", a b.tail to "tail".
Serializacja zaliczyła pięć na pięć: tostring w trybie XML i HTML (HTML poprawnie nie domyka elementów pustych), pretty_print, kanoniczną serializację C14N (method="c14n", kolejna funkcja unikatowa dla lxml) oraz czysty round-trip.
Kodowanie to miejsce, w którym lxml po cichu się wyróżnia. Podaj mu bajty spoza UTF-8 — "<p>café éè</p>".encode("latin-1") przez lxml.html.fromstring — a odzyskuje café éè bez szwanku, bez znaków zastępczych U+FFFD i bez obciętych bajtów. To bezpośrednio powiela jego rolę jako „czystego punktu odniesienia” z pakietu selectolax, gdzie ten sam input cicho się psuł w dwóch pozostałych silnikach (Lexbor zwracał znaki zastępcze, Modest ucinał bajty). Obsługa charsetów w lxml oparta na libxml2 jest tutaj po prostu stabilniejsza.
Druga strona medalu to rygor wobec sposobu, w jaki deklarujesz kodowanie. encoding="latin-1" w deklaracji XML kończy się XMLSyntaxError: Unsupported encoding: latin-1, natomiast IANA-canonical encoding="ISO-8859-1" parsuje się poprawnie i zwraca café. libxml2 akceptuje tylko kanoniczne nazwy kodowań, a nie aliasy — detal opisany już dawno w launchpad #613302. Denerwujące, jeśli o tym nie wiesz; banalne, kiedy już wiesz.
Na koniec cykl życia węzłów. Uruchomiłem trzy scenariusze ze starymi uchwytami w izolowanych podprocesach (twardy crash byłby widoczny jako kod wyjścia różny od zera): trzymanie węzła po zebraniu śmieci przez GC, odczyt uchwytu po drop_tree() oraz użycie węzła po remove(). W żadnym nie było segfaulta — lxml utrzymuje referencję węzła do drzewa, żeby zapobiec use-after-free. Taki sam czysty wynik selectolax dostał w tym teście.
Szybkość i pamięć (zapożyczone, ale uczciwie oznaczone)
Wszystko w tej sekcji pochodzi z pakietu selectolax, stan na 2026-07-13. Ten pakiet nie wygenerował żadnych własnych liczb czasowych i wolę powiedzieć to dwa razy, niż żebyś pomyślał, że cokolwiek przeliczałem na nowo.
| Wymiar | Wartość lxml | Odczyt |
|---|---|---|
| Czysty parse p50 (10 MB) | 77.9 ms | ~33-34% szybciej niż selectolax-Lexbor |
| Pełny parse + ekstrakcja p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | mniej więcej remis z Lexbor przy małych rozmiarach |
| Przepustowość CSS na 100k węzłów | 3,002,646 nodes/s | najszybsza półka spośród trzech silników C |
| Przyrost RSS dla 10 MB | 128.9 MB | najoszczędniejszy z sześciu parserów, ok. 1.7x oszczędniejszy niż BeautifulSoup |
| Zimny start importu | 14.1 ms | ok. 2.3x szybciej niż importy w stylu parsel |
Liczby dla czystego parse’u i przepustowości są mocne, a lxml jest najbardziej oszczędny pamięciowo spośród sześciu mierzonych parserów. Trzeba jednak dodać zastrzeżenie dotyczące wątków. Ponownie użyte dane pokazują przyspieszenie wall-clock dla 4 wątków na poziomie tylko 1.21x, oznaczone jako niejednoznaczne — ale to ścieżka z współdzielonym parserem domyślnym. FAQ lxml jasno mówi, że GIL jest zwalniany podczas parsowania tylko wtedy, gdy każdy wątek używa własnego parsera (albo skopiowanego domyślnego); współdzielony parser serializuje dostęp. Strukturalnie sprawdziłem istnienie odpowiednich elementów API (XMLParser.copy(), get/set_default_parser, XPathEvaluator z wewnętrzną blokadą), ale nie mierzyłem zysku z parsera per-thread — to byłby nowy pomiar, a ten pakiet takich nie generuje. Czytaj więc „1.21x” jako wynik „naiwnej ścieżki współdzielonej”, a nie jako limit lxml dla wielowątkowości.
I jeszcze jedna gwiazdka przy tych liczbach: są to wyniki z jednej platformy, macOS arm64. Twierdzenie, że czysty parse lxml bije Lexbor, idzie pod prąd zwykłemu konsensusowi, zgodnie z którym najszybszy jest parser oparty o Lexbor, więc naprawdę wymaga ponownego sprawdzenia na Linux x86_64, zanim ktokolwiek uzna to za sprawę zamkniętą.
Licencja: nudne, ale ważne zwycięstwo
lxml jest wydawany na licencji BSD-3-Clause, a dołączone biblioteki C — libxml2 i libxslt — mają licencje MIT. To cały łańcuch bez żadnego copyleftu, co ma znaczenie w chwili, gdy zaczynasz redystrybuować produkt. Dla kontrastu, wheel selectolax pakuje LGPL-2.1 Modest i Apache-2.0 Lexbor, więc lxml jest prostszą historią przy wdrożeniu do zamkniętego produktu.
Jest też praktyczna zaleta instalacyjna: lxml publikuje gotowe wheel’e statycznie linkujące libxml2 i libxslt, więc pip install lxml zwykle nie wymaga systemowego libxml2 ani kompilatora na twojej maszynie — to zupełnie inne doświadczenie niż budowanie ze źródeł.
Gdzie lxml pasuje najlepiej — a kiedy warstwę ekstrakcji przejmuje AI
Warto jasno wyznaczyć granicę, bo tu łatwo o pomyłkę kategorii. lxml to biblioteka do parsowania. Daje ci drzewo i świetny silnik zapytań, a wszystko, co dzieje się wokół tego drzewa, nadal należy do ciebie: pobieranie strony, renderowanie JavaScriptu, obchodzenie zabezpieczeń anty-bot, pisanie i utrzymywanie XPath oraz strukturyzowanie wyniku. To inna warstwa niż hostowana usługa ekstrakcji i te dwa światy nie są bardziej rywalami niż sąsiadami.
Dla developera, który nie chce sam utrzymywać całego stosu fetch-render-select-maintain, właśnie tam działa coś takiego jak Thunderbit — a dla tej grupy odbiorców chodzi o API, serwer MCP i CLI, nie o rozszerzenie do przeglądarki. Thunderbit Open API udostępnia POST /distill, które zamienia stronę w czysty Markdown, oraz POST /extract, które pobiera dane strukturalne zgodnie ze schematem JSON, z przełącznikiem renderMode i zadaniami wsadowymi dla większych wolumenów. Ten sam silnik jest dostępny jako serwer MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) dla agentów i asystentów kodu oraz jako CLI uruchamiane prosto z terminala przez npx @thunderbit/thunderbit-cli. Obsługuje renderowanie JS, anti-bot i CAPTCHA od ręki i zwraca JSON zgodny ze schematem — czyli warstwę nad parsowaniem, a nie jego zamiennik.
Wypróbuj Thunderbit do ekstrakcji danych z WWW
Ujęcie jest proste. Sięgaj po lxml, jeśli kontrolujesz własny pipeline i chcesz chirurgicznej kontroli XPath nad drzewem, które rozumiesz. Sięgaj po AI API do ekstrakcji, jeśli nie chcesz w ogóle utrzymywać selektorów i renderowania. Wiele realnych systemów używa obu podejść: lxml do uporządkowanych feedów, które kontrolują, a usługi ekstrakcyjne do brudnego ogona stron, których nie kontrolują.
Czego ten test nie sprawdzał
To jest recenzja wstępna, a nie końcowa tabela wyników, więc oto zakres, którego nie obejmuje.
Wszystkie liczby czasu i pamięci są ponownie użyte, z jednej platformy (macOS arm64, Python 3.14) i dziedziczą zastrzeżenia tamtego pakietu — wynik „lxml jest szybszy w czystym parsowaniu” jest niezgodny z dominującym konsensusem i wymaga ponownego sprawdzenia na Linux x86_64. Przyspieszenie parsera per-thread nie było testowane (wymagałoby nowych pomiarów czasu). Zmierzyłem pamięć iterparse na 300k rekordów, ale nie na realnym XML-u rzędu gigabajtów, nie porównywałem iterparse na HTML vs XML i nie robiłem wielogodzinnego soak testu. XSLT 1.0 w lxml, walidacja RelaxNG / XMLSchema / DTD oraz rozszerzenia EXSLT nie były tu w ogóle testowane — to duża powierzchnia możliwości, ale poza rdzeniem parsowania i selekcji. Zauważyłem drugi limit głębokości na poziomie 2,045, ale nie ustaliłem dokładnej stałej rekurencji libxml2. Testowana była tylko stabilna wersja 6.1.1, nie alpha 7.0.0. Nie testowałem Windowsa, buildów ze źródeł ani free-threaded 3.14t. A w samym XPath sprawdzałem wbudowane funkcje, ale nie zmiennych XPath, nie własnych funkcji rozszerzających Pythona i nie ponownego użycia prekompilowanych obiektów etree.XPath.
Werdykt
lxml nie jest nowym, błyskawicznym cudownym narzędziem — i właśnie dlatego warto po niego sięgać. To dwudziestoletni binding do libxml2 z pełnym silnikiem XPath 1.0, którego żaden mainstreamowy odpowiednik w Pythonie nie przebija, z trzema przewidywalnymi trybami rygoru parsowania i pośrednim error logiem, z prawdziwym parserem strumieniowym dla dokumentów, które nie mieszczą się w pamięci, poprawną obsługą wielu namespace’ów i kodowania oraz z całkowicie liberalną licencją. Kilka ostrzejszych krawędzi — limit głębokości ~253 poziomów i liczba przy parserze współdzielonym przez wątki — jest opisanych, konfigurowalnych i teraz już wyjaśnionych.
Jeśli kontrolujesz własny pipeline scrapujący i mocno polegasz na XPath, lxml nadal jest parserem, po który sięgasz. Jeśli nie chcesz utrzymywać selektorów i renderowania, od tego jest warstwa ekstrakcji AI, taka jak Thunderbit API, MCP i CLI — czytelny podział pracy, a nie konkurencja. W obu przypadkach traktuj te liczby jako wstępne i sprawdź czas na własnej platformie, zanim wpiszesz je do dokumentu projektowego.
Wypróbuj Thunderbit do ekstrakcji danych z WWW Get Started Free
FAQ
Czy lxml to web scraper?
Nie. lxml to parser i serializer — binding Pythona do libxml2/libxslt, który zamienia markup w edytowalne, przeszukiwalne drzewo. Nie pobiera stron, nie renderuje JavaScriptu i nie obsługuje zabezpieczeń anty-bot; warstwę żądań dostarczasz samodzielnie (przez requests, httpx, headless browser albo usługę scrapującą) i przekazujesz bajty do lxml.
Kiedy używać lxml zamiast BeautifulSoup albo selectolax? Sięgaj po lxml, gdy potrzebujesz XPath. BeautifulSoup może używać lxml jako backendowego parsera, ale nie udostępnia natywnego XPath, a selectolax działa tylko na CSS i jest szybszy w wąskim zakresie zastosowań. Jeśli twoja logika selekcji wymaga filtrowania po treści tekstu, przechodzenia do rodzica lub przodka, wydobywania atrybutów albo węzłów tekstowych lub predykatów opartych na liczbie elementów, silnik XPath w lxml jest jedyną mainstreamową opcją w Pythonie, która wyraża to wprost.
Dlaczego lxml po cichu ucina głęboko zagnieżdżoną zawartość?
Domyślny parser ogranicza głębokość do około 253 poziomów — to zabezpieczenie libxml2 przed złośliwymi dokumentami, a nie bug. Ustaw huge_tree=True (na przykład lxml.html.HTMLParser(huge_tree=True)), a odzyskasz w pełni głębokości 300 i 1,000. Pamiętaj jednak o drugim, twardszym limicie rekurencji około 2,045 poziomów, którego huge_tree nie usuwa.
Czy lxml zwalnia GIL przy parsowaniu wielowątkowym? Tylko w odpowiednich warunkach. FAQ lxml mówi, że GIL jest zwalniany podczas parsowania, gdy każdy wątek używa własnego parsera albo skopiowanego domyślnego parsera; wspólny parser zamiast tego serializuje dostęp. Ponownie użyte przyspieszenie 4-wątkowe na poziomie 1.21x odzwierciedla naiwną ścieżkę ze współdzielonym parserem, a nie limit przy parserze na każdy wątek, którego tutaj nie mierzono.
Czy lxml nadal jest utrzymywany w 2026? Tak. Stabilne wydanie 6.1.1 ukazało się 2026-05-18, repozytorium było ostatnio aktualizowane 2026-07-02, a w przygotowaniu jest alpha 7.0.0. Przy około 3,000 gwiazdek na GitHubie i aktywnie utrzymywanym libxml2 pod spodem pozostaje to aktualna, dobrze wspierana biblioteka, a nie relikt.


