Каждые несколько месяцев всплывает очередной более шустрый HTML-парсер, бенчмарки разлетаются по лентам, и кто-то уже спешит списать старую гвардию. А потом тебе нужно выбрать все абзацы, которые содержат нужное слово, или взять родителя найденного узла — и сразу вспоминается, почему lxml всё ещё открыт во вкладке рядом.
lxml — это Python-обёртка над libxml2, которой уже 20 лет. Он не выглядит эффектно. Он не новый. И в одной конкретной задаче — во всём, что требует настоящего XPath — в мейнстримном Python ему, по сути, нет равных. Это практический обзор того, что он умеет, где тихо выигрывает и в каких местах его настройки могут неприятно удивить, если не знать о них заранее.
lxml в одном абзаце: что это на самом деле
lxml — это Python-обёртка для C-библиотек libxml2 и libxslt. Это парсер и сериализатор, а не скрапер и не браузер: он превращает разметку в дерево, с которым можно работать, а потом снова собирает это дерево в байты. Он даёт API, совместимый с ElementTree, полноценный движок XPath 1.0, XSLT 1.0 и валидацию схем. Проект поддерживает Stefan Behnel под девизом "самая функциональная и удобная библиотека для обработки XML и HTML на языке Python".
Вот как он выглядит по состоянию на снимок GitHub и PyPI, сделанный 2026-07-14:
| Поле | Значение |
|---|---|
| Репозиторий | lxml/lxml |
| Звёзды | 3,043 |
| Форки | 620 |
| Открытые issues | 16 |
| Лицензия | BSD-3-Clause |
| Создан | 2011-02-11 |
| Последний push | 2026-07-02 |
| Стабильная версия на PyPI | 6.1.1 (2026-05-18) |
| Встроенный движок | libxml2 2.14.6 + libxslt 1.1.43 |
Сразу оговорюсь, чтобы не было разговоров про хайп: в этом обзоре нет никаких секретов. lxml достаточно стар, и почти каждое его поведение уже где-то описано — в документации lxml, в changelog libxml2 или в одном из launchpad-обсуждений. Я не нашёл ни одного скрытого, неописанного трюка и не собираюсь ничего выдумывать. Ценность ниже — в том, что всё систематизировано, измерено и разложено именно вокруг lxml как объекта обзора, а не в том, что это какая-то сенсация.
Как проводилось тестирование (и почему цифры по времени заимствованы)
В этот обзор входят две категории данных, и получены они из двух разных источников, так что сразу поясню, что откуда.
Проверки возможностей — поведение XPath, два API парсинга, namespaces, кодировка, жизненный цикл узлов — я запускал заново на одной машине: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Все значения в файлах artifacts/raw/*.json вычислены скриптом во время запуска, а не внесены вручную. Проверки возможностей — это детерминированные булевы значения и перечисления, поэтому один прогон стабилен: нагрузка на машину не меняет, вернёт ли //a/@href строку-атрибут.
Числа по времени и памяти — не из этого набора. Они взяты дословно из более раннего набора бенчмарков selectolax — та же машина, та же виртуальная среда, тот же билд lxml и libxml2, замеры по состоянию на 2026-07-13 — и здесь я их не запускал заново. Это сделано намеренно. Если гонять тайминги параллельно с пачкой capability-скриптов, CPU-конкуренция исказит повторно используемые цифры, а дублировать работу нет смысла: в том наборе lxml уже был полноценно измеренной контрольной библиотекой. Повторное использование сохраняет apples-to-apples сравнение вместо того, чтобы вводить второй, слегка отличающийся замер. Поэтому когда ниже увидишь число в миллисекундах, читай его как «тот же стенд, состояние на 2026-07-13», а не как «я замерил это сегодня заново».
Для выводов используется тег уверенности: single-observation для детерминированных capability-тестов, triple-run для повторно использованных распределений времени, hypothesis там, где я предлагаю механизм, который не изолировал.
XPath: единственное, чего нет ни у selectolax, ни у BeautifulSoup
Это главный тезис, так что начну с него.

Я прогнал xpath() в lxml через матрицу из 37 заранее зарегистрированных случаев — ожидаемый результат для каждого был записан в код до запуска теста, так что я не мог случайно "натянуть" оценку. Десять осей, девять типов предикатов, десять встроенных функций, три скалярных типа результата и пять заведомых ловушек с синтаксисом XPath 2.0, который движок XPath 1.0 в lxml должен отвергать.
| Категория | Покрытие | Результат |
|---|---|---|
| Оси | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Предикаты | [1] / last() / position()<n / равенство атрибутов / наличие атрибута / and / or / вложенный [.//a] / not() | 9/9 pass |
| Функции | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 pass |
| Типы результата | boolean / number scalars | 3/3 pass |
| Ловушки | matches() / sequences / if-then-else / except / syntax error | 5/5 correctly rejected |
Итого 37/37, и важнее всего здесь колонка с ловушками. matches(), выражения последовательностей, if/then/else и except — это синтаксис XPath 2.0, и движок libxml2 версии 1.0 не пытается поддерживать его частично: он выбрасывает XPathEvalError и отказывается выполнять запрос, а не молча возвращает неверный набор узлов. Так что это идеальный результат после попытки сломать систему, а не «идеально» потому, что тесты были слишком простыми. Всё поведение здесь ровно такое, как описано в документации XPath в lxml, а в этом и была идея.
Признаю одну ошибку в harness, потому что это как раз тот вариант «37/37», которому можно доверять. Моё первое ожидаемое множество для //div[.//a[@href]] предсказывало два совпадения; фактический запуск вернул одно. Около тридцати секунд я думал, что ошибается lxml, затем проверил фикстуру и обнаружил, что второй элемент был <footer>, а не <div> — ошибся я, а не движок. Я исправил ожидаемое множество и оставил пометку в комментарии к исходнику. Это правильный порядок обвинений: сначала проверь свой тест, а потом уже 20-летнюю C-библиотеку.
XPath против CSS: что в CSS буквально невозможно выразить
Общее утверждение «XPath мощнее» заслуживает конкретных цифр, поэтому я измерил разрыв. В lxml есть и .xpath(), и .cssselect() (второе внутри переводит CSS в XPath). Я взял десять целей и проверил, какие из них CSS действительно может выразить.

| Цель | XPath | CSS (cssselect) |
|---|---|---|
Фильтрация по тексту (contains(text(),"bargain")) | Да | Нет текстового предиката |
Выбрать родителя от дочернего узла (//b/parent::p) | Да | Нет селектора родителя |
Вернуть значение атрибута (//a/@href) | Да | Только элементы |
Вернуть текстовый узел (//p/text()) | Да | Текстовые узлы не поддерживаются |
Ось ancestor (//td/ancestor::div) | Да | Нет навигации вверх |
Фильтрация родителя по числу детей (//ul[count(li)=4]) | Да | Нет предиката по количеству |
Фильтрация по длине текста (string-length(text())>5) | Да | Нет предиката по длине |
nth-child / last-child / adjacent sibling | Да | Да (3 базовых случая) |
Семь из десяти целей CSS вообще не умеет выразить. Фильтрация по тексту, движение вверх к родителям и предкам, получение значения атрибута или "голого" текстового узла в результате, предикаты на основе количества — CSS этого не умеет. Только три случая (nth-child, last-child, соседний sibling) работают в обоих вариантах. Вот и ответ на вопрос «что я реально получаю, когда выбираю lxml». selectolax работает только с CSS и вообще не имеет метода xpath(), поэтому те же семь типов запросов там либо превращаются в многошаговые Python-циклы, либо становятся невозможными. Если твоя логика скрапинга на них завязана — выбор уже сделан.
(И да, harness снова поймал меня на ошибке: я ожидал пустой результат для string-length(text())>5, но совпали две шестисимвольные строки. Исправил ожидание, а не инструмент.)
Три режима строгости: etree, recover и lxml.html
XPath — причина выбрать lxml. Три режима строгости — причина не расставаться с ним.

Большинство парсеров дают одно поведение для битого входа. lxml даёт три, и они достаточно предсказуемы, чтобы я прогнал через каждый шесть классов некорректной разметки и заранее зафиксировал, как должен вести себя каждый путь.
| Некорректный вход | lxml.etree (строгий) | etree + recover=True | lxml.html (мягкий) |
|---|---|---|---|
Незакрытый тег <root><a>x</root> | raises | recovers | accepts |
Неверная вложенность <b><i></b></i> | raises | recovers | accepts |
Неопределённая сущность | raises | recovers | accepts |
Голый & (Tom & Jerry) | raises | recovers | accepts |
Несколько корней <a>1</a><b>2</b> | raises | recovers | accepts |
| Корректный XML | accepts | accepts (0 errors) | accepts |
Булев атрибут <input disabled> | raises | recovers | accepts |
Все семь из семи совпали с заранее заданным ожиданием. lxml.etree выбрасывает XMLSyntaxError на все шесть классов ошибок. Если добавить к тому же парсеру recover=True, он проглатывает ошибки и восстанавливает пригодное дерево — и это недооценённая часть — при этом parser.error_log перечисляет каждую ошибку, которую он проигнорировал. lxml.html принимает всё без возражений.
Классификатор, решающий «выброшено / восстановлено / принято», сам опирается на длину runtime-error_log, а не на жёстко прошитое правило, поэтому корректный документ, пропущенный через recover=True, правильно определяется как "accepts" (пустой лог), а не "recovers". В первой версии классификатора любой результат с recover=True помечался как "recovers", и чистый ввод классифицировался неверно; чтение фактического error_log это исправило.
Что это даёт на практике: нужна жёсткая валидация там, где сломанный поток должен падать с ошибкой — бери lxml.etree. Нужен разбор грязного HTML из реального мира — бери lxml.html. А промежуточный случай, который мало кто умеет: «будь терпимым, но скажи мне точно, что было сломано, чтобы я мог залогировать это» — используй recover=True и читай error log. У selectolax есть только мягкий режим, без строгого режима и без журнала ошибок.
iterparse: потоковый режим, которого у selectolax вообще нет
Это не ускоритель, а характеристика возможностей. selectolax принимает только целую строку — инкрементального интерфейса у него нет. У lxml есть iterparse: он выдаёт элементы по мере закрытия тегов, а в связке с классическим приёмом fast_iter (вызывать elem.clear() и удалять предыдущих siblings по ходу) удерживает память на ровном уровне, сколько бы ни весил документ.

Я измерил это напрямую — пик RSS через ru_maxrss, каждый вариант в отдельном свежем процессе, на 300,000 элементах <record> общей массой около 15 MB.
| Режим | Пик RSS delta | Примечания |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | память освобождается по мере чтения; почти не зависит от объёма |
iterparse без clear | ~386 MB | удерживает ссылки; по тяжести почти как полная загрузка |
etree.parse (полная загрузка, опорный) | ~386 MB | заведомо тяжёлый; показывает, что измеритель видит масштаб |
Ограниченный режим удерживает пик RSS примерно в пределах 1–2 MB против ~386 MB при полной загрузке — разница по масштабу около 0,3–0,4% — и событие первого record срабатывает ещё до того, как файл дочитывается до конца, так что это действительно инкрементальная обработка, а не фальшивый streaming. Самая поучительная строка — средняя. Запусти тот же цикл iterparse, но убери clear(), и память снова вырастет до ~386 MB, потому что ты держишь ссылки на всё подряд. Выигрыш живёт в clear(), а не в iterparse как таковом. Сильный разрыв между полной загрузкой и ограниченным режимом также подтверждает, что RSS-метр действительно видит разницу в масштабе, а не смотрит в пустоту. (Этот тест памяти я запускал уже в рамках данного набора — это замер footprint, а не повторно использованные тайминги.)
Практический вывод: если у тебя есть гигабайтный XML-экспорт, который не помещается в RAM, у selectolax нет вообще никакого пути. Либо потоковый парсер lxml, либо другой язык.
Namespaces: RSS, SVG и ловушка с namespace по умолчанию
Двенадцать сценариев с namespaces: RSS сразу в трёх пространствах имён, SVG с namespace по умолчанию плюс xlink, а также XML с namespace по умолчанию. Все двенадцать прошли.
lxml извлекает //dc:creator/text() из RSS-ленты ровно как ["Alice", "Bob"], находит //atom:link/@href и //content:encoded через три разных namespace в одном документе, обрабатывает //s:rect и //s:use/@xlink:href во втором namespace SVG и разбирает имена в Clark-notation {uri}local через QName, а также показывает map пространств имён через nsmap. Это задокументированное и поддерживаемое поведение, и это целое измерение, которого selectolax вообще не касается, потому что selectolax — только HTML5 и не обрабатывает произвольные XML namespaces.
Есть одна задокументированная ловушка, которую стоит запомнить. У XPath нет понятия namespace по умолчанию. Если направить //book на документ, где объявлен xmlns="urn:...", ты получишь ноль совпадений — пустой префикс для XPath не определён, как и сказано в документации lxml. Нужно либо привязать искусственный префикс (//c:book с namespaces={"c": "urn:..."}, что нашло все три), либо использовать //*[local-name()='book'] (тоже три). Это не баг — это спецификация XPath, реализованная как положено. Просто каждый человек удивляется этому ровно один раз.
Реальные грязные страницы: точность на 11 фактических скрапах
Синтетические тесты чистые; веб — нет. Я повторно использовал одиннадцать реально сохранённых страниц из набора selectolax (состояние на 2026-07-10, только чтение) и прогнал их через lxml.html, сделав lxml главным объектом теста.
| Фикстура | Размер | Ссылки | Ошибки, восстановленные libxml2 | Строгий 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 |
Все одиннадцать страниц разобрались через lxml.html, а количество ссылок, заголовков и изображений совпало с повторно использованными counts из набора selectolax во всех одиннадцати случаях — cross-check true. Именно это говорит мне, что повторное использование честно и сопоставимо, а не что под одинаковой меткой скрываются две разные метрики.
Побочный вывод: строгий XML-парсер дал ошибку на десяти из одиннадцати страниц. Реальные веб-страницы в подавляющем большинстве не являются корректным XML, и именно для этого существует HTML-recovery mode в libxml2. Единственным исключением оказалась BBC News, отрендеренная на Next.js и достаточно корректная, чтобы пройти строгий XML-парсинг. Не всё, что называется "HTML", требует режима восстановления.
Одна тонкость со счётом, на которую легко попасться. В docs_python.html //a[@href] (наличие атрибута) дал 343, а в наборе selectolax if n.get("href") (truthy value) дал 341. Две лишние ссылки — это пустые href="". Это разница в конвенции подсчёта — атрибут существует против атрибута не пустой — а не различие в поведении lxml, и числа сходятся, когда ты приводишь предикат к одной логике. Полезно помнить при скрапинге: считать ли пустые href — это выбор фильтра, а не парсера.
Ограничение глубины, которое выглядит как баг, но им не является
В наборе selectolax уже было отмечено, что lxml теряет самый глубокий контент в разметке с вложенностью на 1,000 и 5,000 уровней <div>, и это было описано как "lxml silently loses the deepest content". Мне захотелось понять механизм, поэтому я прогнал парсер по умолчанию и затем huge_tree=True.

| Запрошенная глубина | Доходит парсер по умолчанию | Доходит при huge_tree=True |
|---|---|---|
| 300 | 253 (дальше обрезает) | 299 (восстановлено) |
| 1000 | 253 (дальше обрезает) | 999 (восстановлено) |
| 5000 | 253 (дальше обрезает) | 2045 (всё ещё обрезает) |
Парсер по умолчанию обрезает дерево примерно на 253 уровнях и молча отбрасывает всё, что глубже. Это не баг — это защита libxml2 от DoS, примерно 256-уровневый лимит вложенности, который не даёт враждебному документу переполнить стек, и он задокументирован в launchpad-треде lxml про XML_PARSE_HUGE. Если поставить huge_tree=True, глубины 300 и 1,000 восстанавливаются полностью. Но на глубине 5,000 даже с huge_tree парсер доходит только до 2,045 — существует второй, более жёсткий потолок рекурсии libxml2 выше настраиваемого, и huge_tree его не снимает.
Практический вывод простой: когда разбираешь очень глубокую разметку из доверенного источника, используй lxml.html.HTMLParser(huge_tree=True). Что добавляет этот набор помимо повторно использованного наблюдения — это механизм (защитный лимит, а не порча данных), способ исправления (huge_tree) и тот факт, что есть ещё один потолок, до которого этот способ не дотягивается.
DOM для чтения и записи, сериализация, кодировки
lxml — это полноценное дерево для чтения и записи, а не только инструмент извлечения данных, и я поэлементно проверил поверхность редактирования. Все восемь DOM-операций прошли: SubElement, insert, remove, replace, strip_tags (убирает теги, сохраняя их текст), strip_elements (убирает теги вместе с текстом), drop_tree (эксклюзив lxml.html) и модель с двумя текстовыми слотами text/tail, которая путает новичков — в <p>head<b>bold</b>tail</p> у p.text значение "head", у b.text — "bold", а у b.tail — "tail".
Сериализация прошла пять из пяти: tostring в XML- и HTML-режиме (в HTML void elements корректно не само-закрываются), pretty_print, каноникализация C14N (method="c14n", ещё один эксклюзив lxml) и чистый round-trip.
Кодировки — это то место, где lxml тихо отстраивается от остальных. Передай ему байты не в UTF-8 — например, "<p>café éè</p>".encode("latin-1") через lxml.html.fromstring — и он восстановит café éè без потерь, без символов замены U+FFFD, без отрезанных байтов. Это напрямую повторяет его роль как "чистого эталона" в наборе selectolax, где тот же вход у двух других движков искажался: Lexbor давал replacement characters, Modest просто отбрасывал байты. Charset detection на базе libxml2 здесь просто стабильнее.
Обратная сторона — строгость к тому, как ты объявляешь кодировку. encoding="latin-1" в XML-декларации вызывает XMLSyntaxError: Unsupported encoding: latin-1, тогда как каноническое имя IANA encoding="ISO-8859-1" разбирается без проблем и возвращает café. libxml2 принимает только канонические имена кодировок, а не алиасы — это зафиксировано ещё в launchpad #613302. Неприятно, если не знать, и тривиально, если знать.
И наконец, жизненный цикл узлов. Я прогнал три сценария со stale-handle в изолированных subprocess'ах (жёсткий crash проявился бы как ненулевой код выхода): удержание узла после сборки его дерева GC, чтение handle после drop_tree() и использование узла после remove(). Ни одного segfault ни в одном случае — lxml удерживает ссылку узла на его дерево, чтобы избежать use-after-free. У selectolax в этом тесте тоже всё чисто.
Скорость и память (заимствованные, но честно обозначенные)
Всё в этом разделе повторно использовано из набора selectolax, состояние на 2026-07-13. Этот набор не произвёл ни одной собственной цифры по времени, и я лучше скажу это дважды, чем ты подумаешь, что я что-то перемерял заново.
| Параметр | Значение lxml | Интерпретация |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | примерно на 33–34% быстрее selectolax-Lexbor |
| Полный parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | примерно на уровне Lexbor на малых размерах |
| Пропускная способность CSS на 100k узлах | 3,002,646 nodes/s | верхний скоростной эшелон среди трёх C-движков |
| Дельта RSS на 10 MB | 128.9 MB | самый экономный из шести парсеров, примерно в 1.7 раза экономнее BeautifulSoup |
| Холодный старт импорта | 14.1 ms | примерно в 2.3 раза быстрее parsel-подобных импортов |
Чистый парсинг и throughput у lxml сильные, и это самый экономный по памяти из шести измеренных парсеров. Но с многопоточностью есть важная оговорка. Повторно использованные данные показывают ускорение wall-clock лишь в 1.21 раза на 4 потоках, и это отмечено как inconclusive — однако там использовался общий default parser. FAQ lxml прямо говорит, что GIL освобождается во время парсинга только тогда, когда у каждого потока свой parser (или копия default parser); общий parser сериализует доступ. Я структурно проверил поверхность API для правильного использования (XMLParser.copy() существует, get/set_default_parser существуют, XPathEvaluator имеет внутренний lock), но не измерял ускорение при parser-per-thread — это был бы новый timing-замер, а этот набор таких замеров не производит. Поэтому 1.21x нужно читать как «при наивном общем parser-path», а не как предел для потоков в lxml.
И ещё одна звёздочка на всём этом: это цифры для одной платформы, macOS arm64. Утверждение, что pure parse у lxml опережает Lexbor, идёт вразрез с обычным консенсусом, где именно Lexbor-движок считается самым быстрым, так что этот результат действительно стоит перепроверить на Linux x86_64, прежде чем считать его окончательным.
Лицензия: скучная, но важная победа
lxml распространяется под BSD-3-Clause, а встроенные C-библиотеки — libxml2 и libxslt — под MIT. Это полностью permissive-цепочка без copyleft на каком-либо этапе, и это важно, как только ты начинаешь распространять продукт. Для сравнения, wheel selectolax включает LGPL-2.1 Modest и Apache-2.0 Lexbor, поэтому lxml — более простой выбор, если ты поставляешь закрытое приложение.
Есть и практическое преимущество при установке: lxml публикует готовые wheels со статически слинкованными libxml2 и libxslt, так что pip install lxml обычно не требует системного libxml2 и компилятора на твоей машине — совсем другой опыт, чем сборка из исходников.
Где lxml уместен — и где уже нужен AI-слой извлечения данных
Важно чётко обозначить границу, потому что здесь легко промахнуться с категорией. lxml — это библиотека парсинга. Она даёт тебе дерево и отличный движок запросов, а всё, что происходит вокруг этого дерева, остаётся твоей задачей: получить страницу, отрендерить JavaScript, пройти антибот-защиту, написать и поддерживать XPath, структурировать результат. Это уже другой слой по сравнению с hosted extraction service, и эти два подхода скорее соседи, чем конкуренты.
Если разработчику не хочется самому владеть стеком fetch-render-select-maintain, то на верхнем уровне как раз и работает что-то вроде Thunderbit — и для этой аудитории важны не расширение браузера, а API, MCP server и CLI. Thunderbit Open API даёт POST /distill, чтобы превратить страницу в чистый Markdown, и POST /extract, чтобы извлечь структурированные данные по JSON Schema, с переключателем renderMode и batch-задачами для больших объёмов. Тот же движок доступен как MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) для агентов и coding assistants, а также как CLI, который можно запускать прямо из терминала через npx @thunderbit/thunderbit-cli. Он из коробки обрабатывает JS-rendering, антибот-защиту и CAPTCHA и возвращает JSON, совпадающий со схемой — то есть это слой над парсингом, а не его замена.
Попробовать Thunderbit для извлечения веб-данных
Логика выбора простая. Бери lxml, когда ты контролируешь pipeline и хочешь точечный XPath по дереву, которое понимаешь. Бери AI API для извлечения, когда не хочешь вообще поддерживать селекторы и рендеринг. Во многих реальных системах используются оба подхода: lxml — для структурированных фидов, которыми ты управляешь, а сервис извлечения — для грязного длинного хвоста страниц, которые тебе не принадлежат.
Что этот обзор не тестировал
Это предварительный обзор, а не финальная таблица результатов, так что вот что в него не входит.
Все цифры по времени и памяти повторно использованы, относятся к одной платформе (macOS arm64, Python 3.14) и наследуют оговорки того набора — результат «lxml быстрее на чистом парсинге» идёт против общего консенсуса и требует перепроверки на Linux x86_64. Ускорение многопоточного парсинга при parser-per-thread не тестировалось (для этого нужны новые timing-замеры). Я измерял память iterparse на 300k record'ов, но не на реальном XML гигабайтного масштаба, не сравнивал iterparse для HTML и XML и не делал многочасовой soak-тест. XSLT 1.0 в lxml, его валидация RelaxNG / XMLSchema / DTD и расширения EXSLT здесь вообще не проверялись — это большой набор возможностей, но он выходит за пределы ядра «парсинг и выборка». Второй потолок глубины на уровне 2,045 я заметил, но точную рекурсивную константу libxml2 не определял. Тестировалась только стабильная версия 6.1.1, а не alpha 7.0.0. Windows, сборка из исходников и free-threaded сборка 3.14t тоже не тестировались. И внутри самого XPath я проверял встроенные функции, но не переменные XPath, не собственные Python extension functions и не повторное использование заранее скомпилированного объекта etree.XPath.
Вердикт
lxml — не новинка с громкой скоростью, и именно в этом его сила. Это 20-летняя обёртка над libxml2 с полноценным XPath 1.0, которой в мейнстримном Python нет равных; с тремя предсказуемыми режимами строгости парсинга и журналом ошибок посередине; с настоящим потоковым парсером для документов, которые не помещаются в память; с корректной поддержкой нескольких namespaces и кодировок; и с полностью permissive-лицензией. Пара острых углов — лимит глубины примерно на 253 уровня и число для shared-parser в многопоточности — задокументированы, настраиваются и теперь понятны.
Если ты контролируешь свой scraping pipeline и опираешься на XPath, lxml по-прежнему тот парсер, к которому стоит тянуться. Если ты не хочешь поддерживать селекторы и рендеринг, для этого и существует AI-слой извлечения вроде Thunderbit API, MCP и CLI — это не конкуренция, а чёткое разделение обязанностей. В любом случае считай эти цифры предварительными и перепроверяй timing на своей платформе, прежде чем цитировать их в design doc.
Попробовать Thunderbit для извлечения веб-данных Get Started Free
Часто задаваемые вопросы
lxml — это веб-скрапер?
Нет. lxml — это парсер и сериализатор, Python-обёртка над libxml2/libxslt, которая превращает разметку в редактируемое и запросопригодное дерево. Он не загружает страницы, не рендерит JavaScript и не занимается антибот-защитой; слой запроса ты предоставляешь сам (requests, httpx, headless browser или scraping service), а lxml уже получает байты.
Когда лучше использовать lxml вместо BeautifulSoup или selectolax?
Брать lxml стоит тогда, когда тебе нужен XPath. BeautifulSoup может использовать lxml как backend-парсер, но не даёт нативного XPath, а selectolax работает только с CSS и силён в своей узкой нише по скорости. Если твоей логике нужны фильтрация по тексту, переход к родителям или предкам, извлечение атрибутов/текстовых узлов или предикаты по количеству, то из мейнстримных Python-инструментов только XPath в lxml выражает это напрямую.
Почему lxml молча обрезает слишком глубоко вложенный контент?
У его парсера по умолчанию есть ограничение вложенности примерно в 253 уровня — это защита libxml2 от DoS, а не баг. Если включить huge_tree=True (например, lxml.html.HTMLParser(huge_tree=True)), глубины 300 и 1,000 восстанавливаются полностью. Но есть и второй, более жёсткий потолок около 2,045 уровней, который huge_tree не снимает.
Освобождает ли lxml GIL при многопоточном парсинге?
Только при правильных условиях. В FAQ lxml сказано, что GIL освобождается во время парсинга, когда у каждого потока свой parser или копия default parser; если parser общий, доступ сериализуется. Повторно использованное ускорение 1.21x на 4 потоках отражает наивный shared-parser путь, а не предел при parser-per-thread — его здесь не измеряли.
Поддерживается ли lxml в 2026 году?
Да. Стабильный релиз 6.1.1 вышел 2026-05-18, последний push в репозиторий был 2026-07-02, и в разработке уже есть alpha 7.0.0. При примерно 3,000 звёздах на GitHub и активно поддерживаемой libxml2 под капотом это по-прежнему актуальная и хорошо поддерживаемая библиотека, а не устаревшее наследие.


