Co kilka miesięcy pojawia się szybszy parser HTML, rankingi krążą po sieci, a ktoś ogłasza, że starzy wyjadacze są już przestarzali. A potem próbujesz zaznaczyć każdy akapit, który zawiera konkretne słowo, albo pobrać element nadrzędny dopasowanego węzła i przypominasz sobie, dlaczego lxml wciąż masz otwarte w innej karcie.
lxml to 20-letnie powiązanie do libxml2. Nie jest efektowne. Nie jest nowe. A do jednego konkretnego zadania — wszystkiego, co wymaga prawdziwego XPath — w głównym nurcie Pythona nikt realnie z nim nie konkuruje. To praktyczny przegląd tego, co potrafi, gdzie po cichu wygrywa i w jakich dwóch miejscach jego domyślne ustawienia mogą ugryźć, jeśli nie wiesz, że tam są.
lxml w jednym akapicie: czym tak naprawdę jest
lxml to biblioteka Pythona będąca powiązaniem do bibliotek C libxml2 i libxslt. To parser i serializer, nie scraper i nie przeglądarka — zamienia markup w drzewo, które można przeszukiwać i edytować, a potem z powrotem w bajty. Oferuje API zgodne z ElementTree, pełny silnik XPath 1.0, XSLT 1.0 i walidację schematów, utrzymywane przez Stefana Behnel’a 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 według zrzutu z GitHuba i PyPI pobranego 2026-07-14:
| Pole | Wartość |
|---|---|
| Repo | lxml/lxml |
| Gwiazdki | 3,043 |
| Forki | 620 |
| Otwarte zgłoszenia | 16 |
| Licencja | BSD-3-Clause |
| Utworzono | 2011-02-11 |
| Ostatni push | 2026-07-02 |
| Stabilna wersja PyPI | 6.1.1 (2026-05-18) |
| Dołączony silnik | libxml2 2.14.6 + libxslt 1.1.43 |
Jedna rzecz na początek, żeby nikt nie oskarżył mnie o hype: w tym przeglądzie nie ma żadnych sekretów. lxml jest wystarczająco stary, by każde zachowanie opisano gdzieś w dokumentacji lxml, changelogu libxml2 albo w wątku na launchpadzie. Nie znalazłem żadnego ukrytego, nieudokumentowanego triku i nie zamierzam go wymyślać. Wartość poniższego tekstu polega na tym, że porządkuje fakty, nadaje im liczby i układa je wokół lxml jako tematu — a nie na tym, że przynosi sensację.
Środowisko testowe (i dlaczego liczby czasowe są zapożyczone)
Do tego przeglądu wchodzą dwie kategorie danych i pochodzą z dwóch różnych miejsc, więc od razu zaznaczam, co jest czym.
Testy możliwości — zachowanie XPath, dwa API parserów, przestrzenie nazw, kodowanie, cykl życia węzłów — uruchomiłem od nowa 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 jest wynikiem skryptu, a nie wpisem ręcznym. Testy możliwości są deterministyczne, więc pojedyncze uruchomienie jest stabilne — obciążenie maszyny nie zmienia tego, czy //a/@href zwróci ciąg atrybutu.
Liczby czasu i zużycia pamięci nie pochodzą z tego pakietu. Zostały bez zmian zapożyczone z wcześniejszego zestawu benchmarków selectolax — ta sama maszyna, to samo środowisko wirtualne, ten sam build lxml i libxml2, benchmarki według stanu na 2026-07-13 — i nie uruchamiałem ich tutaj ponownie. To celowe. Ponowne uruchamianie benchmarków czasowych równolegle z zestawem skryptów sprawdzających możliwości wprowadziłoby rywalizację o CPU i zanieczyściło zapożyczone wyniki, a poza tym byłaby to podwójna robota: lxml był już w tamtym pakiecie w pełni zmierzoną biblioteką referencyjną. Użycie tych samych pomiarów sprawia, że porównanie pozostaje uczciwe, zamiast wprowadzać drugi, subtelnie inny zestaw danych. Dlatego gdy zobaczysz poniżej liczbę w milisekundach, czytaj ją jako „to samo środowisko testowe, stan na 2026-07-13”, a nie „dziś to mierzyłem od nowa”.
Wnioski mają etykiety pewności: single-observation dla deterministycznych testów możliwości, triple-run dla zapożyczonych rozkładów czasowych, 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łówna teza, więc od niej zaczynam.

Przepuściłem xpath() w lxml przez 37-elementową, wcześniej zarejestrowaną matrycę — oczekiwany wynik dla każdego przypadku był zapisany w źródle jeszcze przed uruchomieniem testu, więc nie mogłem niechcący oceniać „na wyrost”. 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 dostępnej tylko w XPath 2.0, którą silnik 1.0 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ść atrybutu / 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 | wartości logiczne / skalarne liczby | 3/3 pass |
| Pułapki | matches() / sekwencje / if-then-else / except / błąd składni | 5/5 poprawnie odrzucone |
Wynik to 37/37, a kolumna pułapek jest najważniejsza. matches(), wyrażenia sekwencji, if/then/else i except to składnia XPath 2.0, a silnik 1.0 w libxml2 nie wspiera ich „połowicznie” — zamiast zwrócić zły zestaw węzłów, zgłasza XPathEvalError i odmawia wykonania. To więc wynik idealny po próbie złamania silnika, a nie wynik zbudowany na łatwych przypadkach. Każde z tych zachowań odpowiada dokładnie temu, co opisuje dokumentacja XPath lxml, i właśnie o to chodzi.
Przyznam też jedną rzecz: generator testów popełnił błąd, ale to ten rodzaj błędu, któremu można ufać, bo został poprawiony. Mój pierwszy oczekiwany zbiór dla //div[.//a[@href]] przewidywał dwa trafienia; wynik dał jedno. Przez około trzydzieści sekund uważałem, że lxml się myli, po czym sprawdziłem fixture i okazało się, że drugi element był <footer>, a nie <div> — to moje założenie było błędne, nie silnik. Poprawiłem oczekiwany zbiór i zostawiłem komentarz o pomyłce w źródle. To właściwa kolejność obwiniania: najpierw podejrzewaj własny test, a dopiero potem 20-letnią bibliotekę C.
XPath kontra CSS: czego dosłownie nie da się wyrazić w CSS
Ogólne stwierdzenie „XPath jest mocniejszy” zasługuje na konkretną liczbę, więc policzyłem 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 CSS potrafi rzeczywiście opisać.

| Cel | XPath | CSS (cssselect) |
|---|---|---|
Filtrowanie po treści tekstu (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 / sąsiedni element | Tak | Tak (3 podstawowe) |
Siedem z dziesięciu celów nie ma żadnego odpowiednika w CSS. Filtrowanie po treści, przechodzenie w górę do rodziców i przodków, pobieranie wartości atrybutu lub surowego węzła tekstowego jako wyniku, predykaty oparte na liczbie elementów — CSS po prostu tego nie potrafi. Tylko trzy (nth-child, last-child, sąsiadujący element) działają w obu. To jest policzona odpowiedź na pytanie „co właściwie zyskuję, sięgając po lxml”. selectolax działa wyłącznie na CSS i w ogóle nie ma metody xpath(), więc te siedem typów zapytań albo staje się tam wieloetapową pętlą Pythona, albo w ogóle nie dochodzi do skutku. Jeśli Twoja logika scrapingu na nich bazuje, decyzja jest już podjęta.
(I tak, tester złapał mnie tu po raz drugi: przewidywałem pusty zbiór dla string-length(text())>5, a dopasowały się dwa sześcioznakowe ciągi. Poprawiłem oczekiwanie, nie narzędzie.)
Trzy tryby rygoru: etree vs recover vs lxml.html
XPath jest powodem, dla którego warto wybrać lxml. Trójstopniowa kontrola rygoru jest powodem, żeby zostać przy tej bibliotece.

Większość parserów daje jedno zachowanie dla uszkodzonych danych. lxml daje trzy i są wystarczająco przewidywalne, że przepuściłem przez każdy z nich sześć klas niepoprawnego markup’u, wcześniej rejestrując, jak ma się zachować każdy wariant.
| Uszkodzony input | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Niezamknię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 logiczny <input disabled> | rzuca wyjątek | odzyskuje | akceptuje |
Siedem na siedem 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 biblioteka połyka błędy i rekonstruuje użyteczne drzewo — i to jest niedoceniana część — parser.error_log wylicza potem każdy z połkniętych błędów. lxml.html akceptuje wszystko bez protestu.
Klasyfikator, który decyduje o tym, czy wynik to „rzuca wyjątek”, „odzyskuje” czy „akceptuje”, opiera się na runtime’owej długości error_log, a nie na twardo wpisanej regule, dlatego poprawnie oznacza poprawny dokument uruchomiony z recover=True jako „akceptuje” (pusty log), a nie „odzyskuje”. Pierwsza wersja tego klasyfikatora traktowała każdy wynik z recover=True jako „odzyskuje” i błędnie oznaczała czysty input; odczyt rzeczywistego error_log to naprawił.
Co to daje w praktyce: gdy potrzebujesz twardej walidacji i uszkodzony feed ma po prostu się wywalić — użyj lxml.etree. Gdy masz brudny, realny HTML, który po prostu trzeba przepuścić — użyj lxml.html. A dla środka, którego większość narzędzi nie obsługuje: „bądź pobłażliwy, ale powiedz mi dokładnie, co było zepsute, żebym mógł to zalogować” — użyj recover=True i czytaj log błędów. selectolax ma tylko tryb pobłażliwy i nic więcej: bez trybu ścisłego i bez logu błędów.
iterparse: tryb strumieniowy, którego selectolax w ogóle nie ma
To jest cecha, nie gałka wydajności. selectolax wczytuje tylko cały string — nie ma interfejsu inkrementalnego. iterparse w lxml zwraca elementy w momencie ich zamykania, a w połączeniu z klasycznym wzorcem fast_iter (wołasz elem.clear() i usuwasz poprzednich sąsiadów w trakcie) utrzymuje pamięć na stałym poziomie niezależnie od rozmiaru dokumentu.

Zmierzylem to bezpośrednio — maksymalny RSS przez ru_maxrss, każdy wariant w osobnym świeżym procesie, na 300,000 elementach <record> o łącznym rozmiarze około 26.7 MB (26,744,801 bajtów).
| Tryb | Przyrost maks. RSS | Uwagi |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | pamięć zwalniana w locie; płasko niezależnie od liczby rekordów |
iterparse bez clear | ~386 MB | trzyma referencje; równie ciężki jak pełne wczytanie |
etree.parse (pełne wczytanie, punkt odniesienia) | ~386 MB | ciężki, ale pokazuje skalę pomiaru |
Tryb ograniczony utrzymuje przyrost maks. RSS na poziomie około 1-2 MB wobec ~386 MB dla pełnego wczytania — to różnica rzędu 0,3-0,4% — a pierwsze zdarzenie rekordu pojawia się jeszcze przed zakończeniem odczytu pliku, więc to naprawdę jest przetwarzanie strumieniowe, a nie udawany streaming. Najbardziej pouczająca jest linia środkowa. Uruchom ten sam loop iterparse, ale pomiń clear(), a pamięć znów dobija do ~386 MB, bo trzymasz referencje do wszystkiego. Zysk siedzi w clear(), nie w samym iterparse. To, że pełne wczytanie jest dużo cięższe niż tryb ograniczony, potwierdza też, że licznik RSS rzeczywiście widzi różnicę skali, a nie mierzy „na ślepo”. (Ten test pamięci wykonał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 ścieżki w selectolax. Zostaje parser strumieniowy lxml albo inny język.
Przestrzenie nazw: RSS, SVG i pułapka domyślnej przestrzeni
Dwanaście przypadków przestrzeni nazw: RSS przez trzy namespace’y, SVG z domyślną przestrzenią i xlink oraz XML z domyślną przestrzenią. Wszystkie dwanaście przeszły.
lxml wyciąga z feedu RSS //dc:creator/text() dokładnie jako ["Alice", "Bob"], rozwiązuje //atom:link/@href i //content:encoded przez trzy oddzielne przestrzenie nazw w tym samym dokumencie, obsługuje //s:rect i //s:use/@xlink:href w drugiej przestrzeni SVG, rozdziela nazwy Clark notation {uri}local przez QName i pozwala introspekcję przez nsmap. To jest zachowanie utrzymywane i opisane w dokumentacji — i cały wymiar, którego selectolax nie dotyka, bo jest wyłącznie HTML5 i nie przetwarza dowolnych przestrzeni nazw XML.
Jest jednak jedna udokumentowana pułapka, którą warto zapamiętać. XPath nie ma pojęcia o domyślnej przestrzeni nazw. Jeśli wskażesz //book dokumentowi deklarującemu xmlns="urn:...", dostaniesz zero trafień — pusty prefiks jest w XPath niezdefiniowany, co dokumentacja lxml wyjaśnia wprost. Trzeba związać sztuczny prefiks (//c:book z namespaces={"c": "urn:..."}, co znalazło wszystkie trzy) albo użyć //*[local-name()='book'] (też trzy). To nie błąd — tak mówi specyfikacja XPath i lxml wiernie ją implementuje. Po prostu zaskakuje każdego dokładnie raz.
Prawdziwy brudny web: zgodność na 11 rzeczywistych scrapach
Testy syntetyczne są czyste; web nie jest. Ponownie wykorzystałem jedenaście prawdziwych, zebranych stron z zestawu fixture’ów pakietu selectolax (stan na 2026-07-10, tylko do odczytu) i przepuściłem je przez lxml.html, traktując lxml jako główny obiekt testu.
| Fixture | Rozmiar | Linki | odzyskane błędy 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 dokumentów sparsowało się przez lxml.html, a liczby linków, nagłówków i obrazów zgadzały się z ponownie wykorzystanymi liczbami z pakietu selectolax we wszystkich jedenastu przypadkach — cross-check true. To właśnie mówi mi, że reuse jest faktycznie porównaniem „jabłka do jabłka”, a nie dwoma różnymi pomiarami z tą samą etykietą.
Poboczny wniosek: strict XML parser zgłosił błąd na dziesięciu z jedenastu stron. Prawdziwe strony WWW są w przeważającej mierze niepoprawnym XML-em, i dokładnie dlatego istnieje tryb odzyskiwania libxml2, żeby je przełknąć. Jedyny wyjątek to BBC News, renderowany przez Next.js i wystarczająco poprawny, by przejść przez strict XML. Nie wszystko, co nazywa się „HTML”, wymaga trybu recovery.
Jest jeszcze jedna rzecz związana z liczeniem, na którą łatwo się nadziać. W docs_python.html //a[@href] (istnienie atrybutu) dało 343, podczas gdy pakiet selectolax liczył przez if n.get("href") (wartość prawdziwa) i dał 341. Te dwa dodatkowe trafienia to puste linki href="". To różnica w konwencji liczenia — atrybut istnieje kontra atrybut jest niepusty — a nie różnica w zachowaniu lxml; liczby się zgadzają, gdy wyrównasz predykat. Warto o tym pamiętać przy scrapingu: to, czy puste hrefy liczą się do wyniku, zależy od filtra, nie od parsera.
Limit głębokości, który wygląda jak błąd, ale nim nie jest
W pakiecie selectolax odnotowano, że lxml gubi najgłębszą treść przy znacznikach <div> zagnieżdżonych na 1,000 i 5,000 poziomach, opisując to jako „lxml po cichu traci najgłębszą zawartość”. Chciałem poznać mechanizm, więc uruchomiłem parser domyślny oraz huge_tree=True.

| Żądana głębokość | Domyślny parser osiąga | huge_tree=True osiąga |
|---|---|---|
| 300 | 253 (reszta odpada) | 299 (odzyskane) |
| 1000 | 253 (reszta odpada) | 999 (odzyskane) |
| 5000 | 253 (reszta odpada) | 2045 (wciąż odpada) |
Domyślny parser ucina się przy około 253 poziomach i po cichu wyrzuca wszystko głębiej. To nie błąd — to zabezpieczenie libxml2 przed DoS, około 256-poziomowy limit zagnieżdżenia, który powstrzymuje złośliwy dokument przed wysadzeniem stosu, i jest to opisane w wątku launchpada lxml dotyczącym XML_PARSE_HUGE. Ustaw huge_tree=True, a głębokości 300 i 1,000 wracają w całości. Przy 5,000 poziomach huge_tree nadal dochodzi tylko do 2,045 — istnieje drugi, twardszy limit rekurencji w libxml2, powyżej tego konfigurowalnego, i huge_tree go nie znosi.
Wniosek jest więc bardzo konkretny: gdy parsujesz bardzo głęboki markup ze źródła, któremu ufasz, użyj lxml.html.HTMLParser(huge_tree=True). To, co ten pakiet dodaje do wcześniejszej obserwacji, to mechanizm (limit bezpieczeństwa, nie uszkodzenie danych), poprawka (huge_tree) i fakt, że istnieje drugi sufit, którego ta poprawka nie dosięga.
DOM do odczytu i zapisu, serializacja, kodowanie
lxml to pełne drzewo do odczytu i zapisu, nie tylko extractor do odczytu, a powierzchnię edycji sprawdziłem przypadek po przypadku. Wszystkie osiem operacji DOM przeszły: SubElement, insert, remove, replace, strip_tags (usuwa tagi, zostawia tekst), strip_elements (usuwa tagi i ich tekst), drop_tree (specyficzne dla lxml.html) oraz model dwóch slotów dla text i tail, który zaskakuje nowicjuszy — w <p>head<b>bold</b>tail</p> p.text to "head", b.text to "bold", a b.tail to "tail".
Serializacja przeszła pięć na pięć: tostring w trybie XML i HTML (HTML poprawnie nie samodomyka pustych elementów), pretty_print, canonicalizacja C14N (method="c14n", kolejna funkcja charakterystyczna dla lxml) oraz czysty round-trip.
Kodowanie to miejsce, w którym lxml dyskretnie odcina się od konkurencji. Podaj mu bajty w kodowaniu innym niż UTF-8 — "<p>café éè</p>".encode("latin-1") przez lxml.html.fromstring — a odzyskuje café éè bez zniekształceń, bez znaków zastępczych U+FFFD i bez gubienia bajtów. To bezpośrednio odtwarza jego rolę jako „czystego punktu odniesienia” w pakiecie selectolax, gdzie ten sam input u dwóch innych silników cicho się psuł (Lexbor produkował znaki zastępcze, Modest gubił bajty). Obsługa wykrywania zestawu znaków w oparciu o libxml2 jest tu po prostu stabilniejsza.
Druga strona medalu to rygor dotyczący tego, jak deklarujesz kodowanie. encoding="latin-1" w deklaracji XML zgłasza XMLSyntaxError: Unsupported encoding: latin-1, natomiast kanoniczne IANA encoding="ISO-8859-1" parsuje się poprawnie i zwraca café. libxml2 akceptuje tylko kanoniczne nazwy kodowań, nie aliasy — detal opisany już dawno w launchpad #613302. Denerwujące, jeśli się tego nie wie; banalne, kiedy już się wie.
Na koniec cykl życia węzła. Uruchomiłem trzy scenariusze z wiszącymi uchwytami w izolowanych procesach potomnych (twardy crash pokazałby się jako niezerowy exit): trzymanie węzła po zebraniu odśmiecenia jego drzewa, odczyt uchwytu po drop_tree() i użycie węzła po remove(). W żadnym przypadku nie było segfaulta — lxml utrzymuje referencję węzła do jego drzewa, by zapobiec use-after-free. Taki sam czysty wynik selectolax dostał w tym teście.
Szybkość i pamięć (zapożyczone, ale uczciwie opisane)
Wszystko w tej sekcji zostało zapożyczone z pakietu selectolax, stan na 2026-07-13. Ten pakiet nie wygenerował własnych liczb czasowych i wolę powiedzieć to dwa razy, niż żebyś pomyślał, że cokolwiek mierzyłem od nowa.
| Wymiar | Wartość lxml | Odczyt |
|---|---|---|
| Czysty parse p50 (10 MB) | 77.9 ms | ~33-34% szybciej niż selectolax-Lexbor |
| Pełny parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | mniej więcej remis z Lexbor przy małych rozmiarach |
| Przepustowość 100k węzłów CSS | 3,002,646 węzłów/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, ~1.7x lżej niż BeautifulSoup |
| Ciepły start importu | 14.1 ms | ~2.3x szybciej niż importy w stylu parsel |
Wyniki czystego parsowania i przepustowości są mocne, a lxml jest najbardziej oszczędny pamięciowo spośród sześciu mierzonych parserów. Obraz wielowątkowości wymaga jednak zastrzeżenia. Zapożyczone dane pokazują przyspieszenie przy 4 wątkach na poziomie tylko 1.21x, oznaczone jako niejednoznaczne — ale to ścieżka z jednym współdzielonym parserem. 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 potwierdziłem dostępność API potrzebnego do zrobienia tego poprawnie (XMLParser.copy() istnieje, get/set_default_parser istnieją, XPathEvaluator ma wewnętrzny lock), ale nie mierzyłem wzrostu wydajności przy parserze per wątek — to byłby nowy pomiar czasowy, a ten pakiet takich danych nie tworzy. Dlatego 1.21x czytaj jako „w naiwnym, współdzielonym wariancie”, a nie jako limit wielowątkowości lxml.
I jedna gwiazdka do całego tego zestawu: to są liczby z jednej platformy, macOS arm64. Twierdzenie, że czysty parse lxml przebija Lexbor, stoi w lekkim sporze z ogólną zgodą, że parser oparty na Lexbor zwykle jest najszybszy, więc naprawdę wymaga ponownego sprawdzenia na Linux x86_64, zanim ktoś uzna to za ostatecznie rozstrzygnięte.
Licencja: nudne zwycięstwo
lxml jest wydawany na licencji BSD-3-Clause, a dołączone biblioteki C — libxml2 i libxslt — są obie na MIT. To w pełni liberalny łańcuch bez żadnego copyleft, co ma znaczenie w chwili redystrybucji. Dla porównania, wheel selectolax dołącza LGPL-2.1 Modest i Apache-2.0 Lexbor, więc lxml jest prostszą historią, jeśli chcesz go dostarczać w produkcie zamkniętym.
Jest też praktyczna korzyść instalacyjna: lxml publikuje gotowe wheel’e, które statycznie linkują libxml2 i libxslt, więc pip install lxml zwykle nie wymaga systemowego libxml2 ani kompilatora na Twojej maszynie — to inne doświadczenie niż budowanie ze źródeł.
Gdzie lxml pasuje — a gdzie przejmuje warstwę ekstrakcji AI
Czas jasno określić granicę, bo łatwo tu o błąd kategorii. lxml jest biblioteką do parsowania. Daje Ci drzewo i świetny silnik zapytań, a wszystko wokół tego drzewa nadal jest Twoim zadaniem: pobranie strony, renderowanie JavaScriptu, przejście przez zabezpieczenia anty-bot, napisanie i utrzymanie XPath oraz ustrukturyzowanie wyniku. To inna warstwa niż hostowana usługa ekstrakcji i te dwa światy nie są konkurentami tak bardzo, jak są sąsiadami.
Dla dewelopera, który woli nie utrzymywać całego stacku fetch-render-select-maintain, wyższa warstwa to miejsce na coś takiego jak Thunderbit — i dla tej grupy ważniejsze są API, serwer MCP i CLI, a nie rozszerzenie przeglądarki. Thunderbit Open API udostępnia POST /distill, które zamienia stronę w czysty Markdown, oraz POST /extract, które wyciąga dane strukturalne według JSON Schema, z przełącznikiem renderMode i zadaniami wsadowymi dla większej skali. Ten sam silnik jest dostępny jako serwer MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) dla agentów i asystentów programistycznych oraz jako CLI uruchamiane bezpośrednio z terminala przez npx @thunderbit/thunderbit-cli. Obsługuje renderowanie JS, anty-bota i CAPTCHA od ręki oraz zwraca JSON zgodny ze schematem — czyli jest warstwą nad parsowaniem, a nie jego zamiennikiem.
Wypróbuj Thunderbit do ekstrakcji danych z sieci
Logika wyboru jest prosta. Sięgnij po lxml, jeśli kontrolujesz pipeline i chcesz chirurgicznej precyzji XPath nad drzewem, które rozumiesz. Sięgnij po API ekstrakcji AI, jeśli w ogóle nie chcesz utrzymywać selektorów i renderowania. Wiele prawdziwych systemów używa obu narzędzi: lxml dla kontrolowanych, strukturalnych feedów, a usługi ekstrakcyjnej dla brudnego long taila stron, których nie kontrolują.
Czego ten przegląd nie testował
To przegląd wstępny, nie końcowa karta wyników, więc uczciwie: czego nie obejmuje.
Wszystkie liczby czasu i pamięci są zapożyczone, z jednej platformy (macOS arm64, Python 3.14) i dziedziczą zastrzeżenia tamtego pakietu — wynik „lxml jest szybszy w czystym parsowaniu” jest sprzeczny z konsensusem i wymaga ponownego sprawdzenia na Linux x86_64. Nie testowałem przyspieszenia per-thread-parser (wymagałoby nowych pomiarów czasowych). Zmierzyłem pamięć iterparse na 300k rekordach, ale nie na rzeczywistym XML liczącym gigabajty, nie iterparse na HTML kontra XML i nie wielogodzinne testy stabilności. XSLT 1.0 lxml, walidacja RelaxNG / XMLSchema / DTD i rozszerzenia EXSLT w ogóle nie były tu testowane — to duża powierzchnia możliwości, ale poza rdzeniem parsowania i selekcji. Zaobserwował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. Windows, build ze źródeł i bezwątkowy build 3.14t nie były testowane. A w samym XPath sprawdziłem wbudowane funkcje, ale nie zmienne XPath, niestandardowe funkcje Pythona ani ponowne użycie prekompilowanego obiektu etree.XPath.
Werdykt
lxml nie jest szybkim nowym wynalazkiem, i właśnie dlatego warto go polecić. To dwudziestoletnie powiązanie do libxml2 z pełnym silnikiem XPath 1.0, którego nie dorównuje żadna mainstreamowa alternatywa w Pythonie, trzema przewidywalnymi trybami rygoru parsowania z logiem błędów pośrodku, prawdziwym parserem strumieniowym dla dokumentów, które nie mieszczą się w pamięci, poprawną obsługą wielu przestrzeni nazw i kodowań oraz w pełni liberalną licencją. Dwie ostre krawędzie — limit głębokości około 253 poziomów i wynik przy współdzielonym parserze w wielowątkowości — są udokumentowane, konfigurowalne i właśnie wyjaśnione.
Jeśli kontrolujesz własny pipeline scrapingu i opierasz się na XPath, lxml nadal jest parserem, po który sięgasz. Jeśli wolisz nie utrzymywać selektorów i renderowania, od tego jest warstwa ekstrakcji AI, taka jak Thunderbit API, MCP i CLI — czysty podział obowiązków, nie konkurencja. Tak czy inaczej, traktuj te liczby jako wstępne i sprawdź wydajność na własnej platformie, zanim przytoczysz je w dokumencie projektowym.
Wypróbuj Thunderbit do ekstrakcji danych z sieci Get Started Free
FAQ
Czy lxml to web scraper?
Nie. lxml to parser i serializer — powiązanie Pythona z libxml2/libxslt, które zamienia markup w edytowalne, możliwe do przeszukiwania drzewo. Nie pobiera stron, nie renderuje JavaScriptu i nie obsługuje zabezpieczeń anty-bot; warstwę żądań dostarczasz samodzielnie (np. przez requests, httpx, headless browser albo usługę scrapingu), a następnie przekazujesz bajty do lxml.
Kiedy używać lxml zamiast BeautifulSoup lub selectolax? Sięgaj po lxml, gdy potrzebujesz XPath. BeautifulSoup może używać lxml jako backendu parsera, ale nie oferuje natywnego XPath, a selectolax działa tylko na CSS i jest szybszy w swoim wąskim zastosowaniu. Jeśli Twoja logika selekcji wymaga filtrowania po treści tekstu, przechodzenia do rodziców lub przodków, pobierania atrybutów lub węzłów tekstowych albo predykatów opartych na liczbie elementów, silnik XPath w lxml to jedyna mainstreamowa opcja w Pythonie, która wyraża to bezpośrednio.
Dlaczego lxml po cichu ucina głęboko zagnieżdżoną treść?
Domyślny parser ogranicza zagnieżdżenie do około 253 poziomów — to zabezpieczenie libxml2 przed DoS, a nie błąd. Ustaw huge_tree=True (na przykład lxml.html.HTMLParser(huge_tree=True)), a odzyska 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 wielowątkowym parsowaniu? 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 kopii domyślnego parsera; współdzielony parser serializuje dostęp. Zapożyczony wynik przy 4 wątkach na poziomie 1.21x odzwierciedla naiwną ścieżkę ze współdzielonym parserem, a nie sufit per-thread-parser, którego tutaj nie mierzono.
Czy lxml jest nadal utrzymywany w 2026? Tak. Stabilne wydanie 6.1.1 ukazało się 2026-05-18, ostatni push w repozytorium był 2026-07-02, a w toku jest alpha 7.0.0. Przy około 3,000 gwiazdek na GitHubie i aktywnie utrzymywanym libxml2 pod spodem pozostaje aktualną, dobrze wspieraną biblioteką, a nie reliktem.


