Каждые несколько месяцев всплывает новый, ещё более быстрый HTML-парсер, бенчмарк разлетается по сети, и кто-то уже хоронит старую школу. Но стоит тебе выбрать все абзацы, которые содержат определённое слово, или получить родителя совпавшего узла, и сразу вспоминаешь, почему lxml всё ещё открыт в соседней вкладке.
lxml — это 20-летняя обёртка для libxml2. Тут нет вау-эффекта. Он не новый. Но когда нужна одна конкретная вещь — настоящий XPath — в mainstream Python ему, по сути, нет равных. Это практический обзор того, что он умеет, где тихо выигрывает и в каких паре мест его настройки по умолчанию могут тебя подловить, если ты о них не знаешь.
lxml в одном абзаце: что это на самом деле
lxml — это Python-обёртка над C-библиотеками libxml2 и libxslt. Это парсер и сериализатор, а не скрапер и не браузер: он превращает разметку в дерево, по которому можно ходить и которое можно редактировать, а потом снова превращает это дерево в байты. Он даёт совместимый с ElementTree API, полноценный движок XPath 1.0, XSLT 1.0 и валидацию схем, а поддерживается Stefan Behnel под слоганом "the most feature-rich and easy-to-use library for processing XML and HTML in the Python language".
Вот как он выглядит по состоянию на снимок 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 — и я их здесь не пересчитывал. Это сделано намеренно. Запускать временные бенчмарки параллельно с пачкой тестов на возможности — значит создавать конкуренцию за CPU и загрязнять повторно используемые цифры. К тому же это было бы дублированием работы: lxml в том пакете уже был полностью измеренной контрольной библиотекой. Повторное использование тех же бенчмарков сохраняет сопоставимость, вместо того чтобы вносить вторую, слегка отличающуюся метрику. Поэтому если ниже ты видишь значение в миллисекундах, читай его как "тот же стенд, состояние на 2026-07-13", а не как "это я замерил сегодня заново".
У каждого вывода есть тег уверенности: single-observation для детерминированных тестов на возможности, 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 ошибся, а потом проверил fixture и обнаружил, что второй элемент был <footer>, а не <div> — ошибкой было моё ожидание, а не движок. Я исправил ожидаемое множество и оставил комментарий с этой ошибкой в исходниках. Это правильный порядок обвинений: сначала подозревай свой тест, а уже потом 20-летнюю C-библиотеку.
XPath против CSS: то, что CSS буквально не может выразить
Общее утверждение «XPath мощнее CSS» стоит подкрепить цифрами, поэтому я измерил разрыв. В 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, adjacent sibling). Вот и количественный ответ на вопрос «что я реально получаю, если беру lxml». selectolax работает только через CSS и вообще не имеет метода xpath(), поэтому эти семь типов запросов там либо превращаются в многошаговые Python-циклы, либо не выполняются вовсе. Если твоя логика скрапинга опирается хотя бы на один из них, выбор уже сделан.
(И да, harness снова поймал меня: я ожидал пустое множество для string-length(text())>5, но совпали два шестибуквенных значения. Исправил ожидание, а не инструмент.)
Три уровня строгости: etree, recover и lxml.html
XPath — причина выбрать lxml. Трёхскоростной контроль строгости — причина его не бросать.

У большинства парсеров есть одно поведение для битого входа. У lxml — три, и они достаточно предсказуемы: я прогнал через каждый шесть классов некорректной разметки и заранее зафиксировал, как должен вести себя каждый путь.
| Некорректный вход | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Незакрытый тег <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 принимает всё без возражений.
Классификатор, который решает "ошибка / восстановление / принято", опирается на длину error_log во время выполнения, а не на жёстко заданное правило, поэтому корректный документ под recover=True правильно получает метку "accepts" (пустой лог), а не "recovers". Первая версия этого классификатора помечала любой результат с recover=True как "recovers" и ошибочно отмечала чистый ввод; проверка реального error_log это исправила.
Что это даёт на практике: строгая валидация, где сломанный фид должен падать громко — используй lxml.etree. Грязный HTML из реального мира, который просто нужно проглотить, — используй lxml.html. А для промежуточного случая, который многие инструменты не умеют: "будь терпимым, но точно скажи, что было сломано, чтобы я мог это залогировать" — используй recover=True и читай error log. У selectolax есть только lenient-режим, без strict mode и без error log.
iterparse: потоковый режим, которого у selectolax вообще нет
Это уже не про скорость, а про саму возможность. selectolax принимает только целую строку целиком — инкрементального интерфейса нет. У lxml iterparse отдаёт элементы по мере их закрытия, а вместе с классическим паттерном fast_iter (вызывать elem.clear() и удалять предыдущих siblings по ходу) он держит память почти ровной независимо от размера документа.

Я измерил поведение по памяти напрямую — peak RSS через ru_maxrss, каждый тест в отдельном свежем процессе, на 300,000 элементах <record> общим объёмом около 26.7 MB (26,744,801 байт).
| Режим | Пиковый прирост RSS | Примечания |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | освобождается по ходу; почти ровно независимо от количества |
iterparse без clear | ~386 MB | держит ссылки; по тяжести почти как полная загрузка |
etree.parse (полная загрузка, anchor) | ~386 MB | ожидаемо тяжёлый; показывает, что измеритель видит масштаб |
Ограниченный режим держит пиковый RSS примерно на уровне 1-2 MB против ~386 MB у полной загрузки — разница порядка 0.3-0.4% — и первое событие записи появляется ещё до того, как файл дочитан до конца, так что это действительно инкрементальная обработка, а не фальшивый streaming. Самая поучительная строка — средняя. Запусти тот же самый цикл iterparse, но пропусти clear(), и память снова вырастет до ~386 MB, потому что ты держишь ссылки на всё подряд. Выигрыш живёт в clear(), а не в iterparse сам по себе. Полная загрузка как якорь также показывает значительно более высокий расход, подтверждая, что RSS-метр действительно видит разницу, а не работает вслепую. (Этот тест на память я запускал уже в данном пакете — это именно измерение footprint, а не повторно использованные временные цифры.)
Практический вывод очевиден: если у тебя XML-экспорт на несколько гигабайт, который не помещается в RAM, у selectolax нет пути вообще. Либо потоковый парсер lxml, либо другой язык.
Namespaces: RSS, SVG и ловушка default namespace
Двенадцать случаев с namespaces: RSS сразу в трёх пространствах имён, SVG с default namespace плюс xlink и XML с default namespace. Все двенадцать прошли.
lxml вытаскивает //dc:creator/text() из RSS-ленты ровно как ["Alice", "Bob"], находит //atom:link/@href и //content:encoded сразу через три разных namespace в одном документе, обрабатывает //s:rect и //s:use/@xlink:href во втором пространстве имён SVG, разделяет Clark-notation {uri}local через QName и умеет смотреть на nsmap. Это документированное и поддерживаемое поведение, и это целое измерение, до которого selectolax вообще не добирается, потому что selectolax работает только с HTML5 и не обрабатывает произвольные XML namespaces.
Есть одна документированная ловушка, которую стоит запомнить. В XPath нет понятия default namespace. Если сделать запрос //book к документу, где объявлено xmlns="urn:...", ты получишь ноль совпадений — пустой префикс в XPath не определён, как и сказано в документации lxml. Нужно либо привязать искусственный префикс (//c:book с namespaces={"c": "urn:..."}, что нашло все три элемента), либо использовать //*[local-name()='book'] (тоже три совпадения). Это не баг — это спецификация XPath, реализованная как надо. Просто почти всех это удивляет ровно один раз.
Реальные грязные страницы: точность на 11 настоящих скрапах
Синтетические тесты всегда аккуратны; веб — нет. Я повторно использовал одиннадцать реально сохранённых страниц из набора selectolax (состояние на 2026-07-10, только для чтения) и прогнал их через lxml.html, сделав lxml главным объектом проверки.
| Fixture | Размер | Ссылки | libxml2 recovered errors | 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 |
Все одиннадцать страниц разобрались через lxml.html, а количество ссылок, заголовков и изображений полностью совпало с ранее измеренными значениями lxml из пакета selectolax на всех одиннадцати — cross-check true. Именно это совпадение показывает, что повторное использование действительно сравнивает одно и то же, а не две разные метрики с одинаковой подписью.
Побочный вывод: строгий XML-парсер выдал ошибку на десяти из одиннадцати страниц. Реальные веб-страницы в подавляющем большинстве не являются корректным XML, и именно поэтому в libxml2 есть HTML recovery mode, который умеет их переваривать. Единственным исключением оказалась BBC News, отрендеренная через Next.js и достаточно аккуратная, чтобы пройти строгий XML-парсинг. Не всё, что подписано как "HTML", требует recovery path.
Есть ещё одна тонкость в подсчёте, на которой легко споткнуться. На docs_python.html //a[@href] (проверка на наличие атрибута) дал 343, тогда как в пакете selectolax if n.get("href") (проверка на truthy-значение) дал 341. Два лишних случая — это пустые ссылки href="". Это разница в соглашении о подсчёте — атрибут существует или атрибут непустой — а не разница в поведении lxml. При скрапинге это важно: считать ли пустые href зависит от твоего фильтра, а не от парсера.
Ограничение глубины, которое выглядит как баг, но им не является
В пакете selectolax было зафиксировано, что lxml отбрасывает самый глубокий контент в разметке с вложенностью на 1,000 и 5,000 уровней, и это было подано как "lxml молча теряет самый глубокий контент". Мне нужен был механизм, поэтому я прогнал стандартный парсер и вариант с 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 — это полноценное дерево для чтения и изменения, а не только extractor, и я по пунктам проверил поверхность редактирования. Все восемь 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 вставлял символы замены, Modest выбрасывал байты). В этом отношении определение кодировки на базе libxml2 просто стабильнее.
Обратная сторона — строгость к тому, как ты укажешь кодировку. encoding="latin-1" в XML-декларации вызывает XMLSyntaxError: Unsupported encoding: latin-1, тогда как IANA-каноническое encoding="ISO-8859-1" разбирается нормально и возвращает café. libxml2 принимает только канонические имена кодировок, а не алиасы — это, кстати, задокументировано ещё в launchpad #613302. Раздражает, если этого не знать, и становится мелочью, когда знаешь.
И наконец, жизненный цикл узлов. Я прогнал три сценария с "протухшими" handles в изолированных subprocess'ах (жёсткий сбой проявился бы как ненулевой код выхода): удержание узла после сборки мусора у его дерева, чтение 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 throughput на 100k узлов | 3,002,646 nodes/s | самый быстрый класс из трёх C-движков |
| RSS delta на 10 MB | 128.9 MB | самый экономный из шести парсеров, примерно в 1.7 раза экономнее BeautifulSoup |
| Холодный старт импорта | 14.1 ms | примерно в 2.3 раза быстрее, чем импорты в стиле parsel |
Цифры по чистому парсингу и throughput сильные, и lxml — самый экономный по памяти из шести измеренных парсеров. Но с многопоточностью есть оговорка. Повторно использованные данные показывают ускорение wall-clock на 4 потоках всего в 1.21x, и это помечено как 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 публикует готовые бинарные wheel-пакеты, которые статически линкуют libxml2 и libxslt, так что pip install lxml обычно не требует ни системного libxml2, ни компилятора на твоей машине — совсем другой опыт по сравнению со сборкой из исходников.
Где lxml уместен — и где эстафету забирает AI-слой извлечения данных
Пора чётко обозначить границу, потому что здесь легко перепутать уровни. lxml — это библиотека парсинга. Она даёт тебе дерево и отличный query engine, а всё, что находится вокруг этого дерева, всё ещё твоя забота: получить страницу, отрендерить JavaScript, пройти антибот-защиту, написать и поддерживать XPath, структурировать результат. Это другой слой по сравнению с облачным сервисом извлечения данных, и конкуренты они не прямые, а скорее соседи.
Для разработчика, который не хочет сам владеть всей цепочкой fetch-render-select-maintain, верхний слой — это место для чего-то вроде Thunderbit, и для этой аудитории важны не browser extension, а API, MCP server и CLI. Thunderbit Open API предлагает POST /distill, чтобы превратить страницу в чистый Markdown, и POST /extract, чтобы извлекать структурированные данные по JSON Schema, с переключателем renderMode и batch jobs для больших объёмов. Тот же движок доступен как MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) для агентов и coding assistants, а также как CLI, который можно запустить прямо из терминала через npx @thunderbit/thunderbit-cli. Он обрабатывает JS-rendering, антибот-защиту и CAPTCHA out of the box и возвращает JSON, совпадающий со схемой, — то есть это слой над парсингом, а не его замена.
Попробовать Thunderbit для извлечения веб-данных
Логика тут простая. Бери lxml, если ты контролируешь пайплайн и тебе нужен хирургически точный XPath по понятному тебе дереву. Бери AI API для извлечения, если не хочешь вообще поддерживать селекторы и рендеринг. На практике многие системы используют оба подхода: lxml — для структурированных фидов, которыми ты управляешь, а сервис извлечения — для грязного длинного хвоста страниц, которыми ты не управляешь.
Что этот обзор не проверял
Это предварительный обзор, а не финальная таблица результатов, поэтому вот что он не покрывает.
Все цифры по времени и памяти повторно использованы, привязаны к одной платформе (macOS arm64, Python 3.14) и наследуют оговорки исходного пакета — результат о том, что lxml быстрее на чистом парсинге, идёт против ожиданий и требует перепроверки на Linux x86_64. Ускорение многопоточного парсинга при parser-per-thread не тестировалось (это потребовало бы новых timing-замеров). Я измерял память iterparse на 300k записей, но не на реальном XML гигабайтного масштаба, не iterparse для HTML против XML и не многочасовой soak-test. 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 — это не новая быстрая модная штука, и именно поэтому его стоит рекомендовать. Это двухдесятилетняя обёртка libxml2 с полноценным движком XPath 1.0, которому в mainstream Python нет альтернатив, тремя предсказуемыми режимами строгости парсинга с error log посередине, настоящим потоковым парсером для документов, которые не помещаются в память, корректной работой с несколькими namespaces и кодировками и полностью permissive-лицензией. Пара острых углов — лимит глубины около 253 уровней и показатель при shared-parser threading — документированы, настраиваемы и теперь объяснены.
Если ты владеешь своим scraping-пайплайном и активно опираешься на 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-сервис — а потом передаёшь байты в lxml.
Когда лучше использовать lxml вместо BeautifulSoup или selectolax? Бери lxml, когда тебе нужен XPath. BeautifulSoup может использовать lxml как backend-парсер, но не даёт нативный XPath, а selectolax работает только через CSS и силён в своей узкой нише скорости. Если твоей логике нужны фильтрация по тексту, переход к родителям или предкам, извлечение атрибутов или текстовых узлов, либо предикаты по количеству, то движок XPath в lxml — по сути единственный mainstream-вариант в Python, который выражает это напрямую.
Почему 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 потоках отражает наивный путь с общим parser, а не потолок при parser-per-thread, который здесь не измерялся.
Поддерживается ли lxml в 2026 году? Да. Стабильный релиз 6.1.1 вышел 2026-05-18, последний push в репозиторий был 2026-07-02, и сейчас идёт работа над alpha 7.0.0. При примерно 3,000 звёзд на GitHub и активно поддерживаемом libxml2 под капотом это по-прежнему актуальная и хорошо поддерживаемая библиотека, а не устаревший legacy-артефакт.


