数か月おきに、より速いHTML parserが登場し、benchmarkが話題になって、もう古いものは不要だと誰かが言い出します。けれど、特定の単語を含む段落を全部選びたい、あるいは一致したノードの親を取りたい、となった瞬間、なぜ lxml が今も別タブで開きっぱなしなのかを思い出すはずです。
lxmlは、20年以上前からある libxml2 のバインディングです。派手さはありません。新しくもありません。ですが、ひとつだけはっきりした役割があります。つまり、本物の XPath が必要な処理です。主流のPython環境で、ここに真正面から競争できるものは他にありません。これは、lxmlが何をして、どこで静かに勝ち、そしてどこでデフォルト設定に気づかないと痛い目を見るのかを、実際に触って確かめたレビューです。
lxmlを一言でいうと何か
lxmlは、Cライブラリ libxml2 と libxslt へのPythonバインディングです。scraperでもbrowserでもなく、parser兼serializerです。マークアップを木構造に変換して検索・編集できるようにし、最後はその木をバイト列に戻します。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" という標語のもとで保守されています。
2026-07-14時点で、GitHubとPyPIのスナップショットから見ると位置づけは次のとおりです。
| 項目 | 値 |
|---|---|
| リポジトリ | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Open issues | 16 |
| License | BSD-3-Clause |
| Created | 2011-02-11 |
| Last push | 2026-07-02 |
| PyPI stable | 6.1.1 (2026-05-18) |
| Bundled engine | libxml2 2.14.6 + libxslt 1.1.43 |
先にひとつだけ釘を刺しておきます。このレビューに、隠しネタはありません。lxmlは十分に古いので、ここで触れる挙動はすべて lxml docs や libxml2 の変更履歴、あるいは launchpad のスレッドのどこかに書かれています。未公開の裏技は見つかりませんでしたし、勝手に作るつもりもありません。以下の価値は、新情報だからではなく、体系化され、数値化され、lxmlを主役として整理されていることにあります。
テスト環境(そしてタイミング数値を借りている理由)
このレビューには2種類のデータがあり、それぞれ別の場所から来ています。そこは先に明確にしておきます。
機能テスト──XPathの挙動、2つのparser API、namespaces、encoding、nodeのライフサイクル──は、1台のマシンで新規に実行しました。macOS arm64、Python 3.14.2、lxml 6.1.1、libxml2 2.14.6 です。artifacts/raw/*.json の数値はすべてスクリプト実行で算出したもので、手入力ではありません。機能テストは決定的な真偽値や列挙型なので、1回の実行で十分安定しています。マシンの負荷が変わっても、//a/@href が属性文字列を返すかどうかは変わりません。
実行時間とメモリ使用量の数値は、このパックで新たに計測したものではありません。 以前の selectolax benchmarkパックからそのまま再利用しています。条件は同じマシン、同じ仮想環境、同じ lxml と libxml2 のビルド、benchmark時点は 2026-07-13 です。ここでは再実行していません。これは意図的です。機能スクリプトと並べて timing benchmark を再実行するとCPU競合が起き、再利用した数値が汚れるおそれがありますし、重複作業にもなります。lxml はそのパックで既に十分に計測済みの比較対象でした。同じbenchmarkを使い回すことで、比較条件を揃えたままにできます。なので、下のミリ秒の数値は「同じ試験環境で、2026-07-13時点のもの」と読んでください。「今日、私が再計測した」という意味ではありません。
評価には信頼度タグを付けています。single-observation は決定的な機能テスト、triple-run は再利用した timing 分布、hypothesis は、仕組みとしてはありそうだが切り分けていないものです。
XPath:selectolax と BeautifulSoup にない唯一の強み
これが本題なので、まずここから入ります。

lxml の xpath() に、事前登録した37項目のマトリクスを通しました。各ケースの期待値はテスト実行前にソースへ書き込んであるので、後から甘く採点する余地はありません。10のaxis、9種類のpredicate、10の組み込みfunction、3つのスカラー戻り値型、そして lxml の 1.0エンジンなら拒否すべき XPath 2.0 専用構文のトラップケース5つです。
| カテゴリ | 対象 | 結果 |
|---|---|---|
| 軸 | 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 スカラー | 3/3 pass |
| トラップケース | matches() / sequences / if-then-else / except / syntax error | 5/5 正しく拒否 |
スコアは37/37です。そして重要なのはトラップ列です。matches()、sequence式、if/then/else、except はすべて XPath 2.0 の構文ですが、libxml2 の 1.0エンジンはそれらを中途半端に受け付けたりはしません。XPathEvalError を出してきちんと拒否します。つまり、曖昧な穴埋め問題ではなく、壊すつもりで試したうえでの満点です。ここでの挙動はすべて lxml XPath docs に書かれている通りで、それがまさにポイントです。
ひとつだけ、ハーネスの側が間違っていたことも認めておきます。信頼できる「37/37」の方です。//div[.//a[@href]] の期待値を最初は2件と見積もっていましたが、実行結果は1件でした。最初は約30秒ほど lxml を疑ったのですが、fixture を確認すると2つ目の要素は <footer> であって <div> ではありませんでした。間違っていたのは期待値のほうです。修正して、コメントに残しました。責任の向きとしては正しい順番です。まず自分のテストを疑い、20年物のCライブラリをその次に疑うべきです。
XPathとCSS:CSSではそもそも表現できないこと
「XPath のほうが強い」という抽象論だけでは弱いので、差を数値化しました。lxml には .xpath() と .cssselect() の両方があります(後者は内部でCSSをXPathに変換します)。10個の選択対象を用意して、CSSで本当に表現できるかを確認しました。

| 対象 | XPath | CSS (cssselect) |
|---|---|---|
テキスト内容で絞り込む (contains(text(),"bargain")) | Yes | No text predicate |
子要素から親を選ぶ (//b/parent::p) | Yes | No parent selector |
属性値を返す (//a/@href) | Yes | Elements only |
テキストノードを返す (//p/text()) | Yes | No text nodes |
ancestor 軸 (//td/ancestor::div) | Yes | No upward navigation |
子要素数で親を絞る (//ul[count(li)=4]) | Yes | No count predicate |
テキスト長で絞る (string-length(text())>5) | Yes | No length predicate |
nth-child / last-child / adjacent sibling | Yes | Yes (3 baseline) |
10個中7個は、CSSではそもそも表現できません。テキスト内容でのfilter、親や祖先へ戻るnavigation、属性値や素のtext nodeを結果として取り出すこと、数を基準にしたpredicate──CSSはこれらを語れません。両方でいけるのは3つ(nth-child、last-child、隣接兄弟)だけです。これが、「lxml を使うと何が増えるのか」という問いへの定量的な答えです。selectolax はCSS専用で、xpath() メソッド自体がありません。なので、その7種類のクエリは、あちらではPythonの多段ループに分解するか、あるいはやらないかのどちらかです。scrapingロジックがそこに依存しているなら、答えはもう出ています。
(ちなみに、ハーネスはここでも私を捕まえました。string-length(text())>5 は空集合だと予想したのですが、6文字の文字列が2つ一致しました。期待値を直したのであって、ツールを直したわけではありません。)
厳格さの3段階:etree、recover、lxml.html
XPath が lxml を選ぶ理由だとしたら、この3段階の厳格さが、使い続ける理由です。

多くのparserは、壊れた入力に対して1種類の挙動しか持ちません。lxml は3種類あります。そして十分に予測可能なので、6種類の不正な markupをそれぞれに流し込み、各経路の動作を事前登録しておきました。
| 不正な入力 | 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 |
7件中7件が事前登録の期待どおりでした。lxml.etree は、6種類すべての不正ケースで XMLSyntaxError を投げます。同じparserに recover=True を付けると、エラーを飲み込んで使える木構造を再構築します。そして見落とされがちな点ですが、そのとき parser.error_log が飲み込んだエラーをすべて列挙してくれます。lxml.html は、何事もなかったように全部受け入れます。
「raises / recovers / accepts」を判定する分類器は、ハードコードではなく実行時の error_log の長さで動いています。そのため、recover=True でも正常な文書は空ログなら正しく「accepts」と判定され、「recovers」にはなりません。最初の版では recover=True なら無条件に「recovers」としてしまい、きれいな入力を誤分類していました。実際の error_log を見るようにしたら直りました。
実務上の意味はこうです。壊れたフィードを厳格に落としたいなら lxml.etree。汚れた現実のHTMLを、とにかく通したいなら lxml.html。そして、多くのtoolが苦手な「寛容に読みたい。でも何が壊れていたかは正確に知りたい」という中間解が欲しいなら、recover=True を使って error log を読めばいいのです。selectolax にあるのはこの“寛容”側だけで、厳格モードも error log もありません。
iterparse:selectolax にまったくないストリーミング機能
これは速度の話ではなく、機能の話です。selectolax は文字列全体を一括で読み込むだけで、段階的なインターフェースはありません。lxml の iterparse は、要素が閉じるたびに順に返してくれます。おなじみの fast_iter パターン(elem.clear() と前の兄弟要素の削除を都度行うやり方)と組み合わせれば、文書がどれだけ大きくてもメモリ使用量をほぼ一定に保てます。

メモリ特性は直接測りました。ru_maxrss によるピークRSS、各対象を新しいprocessで実行、対象は 300,000 個の <record> 要素で総サイズは約 26.7 MB(26,744,801 bytes)です。
| モード | ピークRSS差分 | 備考 |
|---|---|---|
iterparse + clear (fast_iter) | 約1-2 MB | 進みながら解放。件数に関係なく平坦 |
iterparse without clear | 約386 MB | 参照を保持するのでフルロード並み |
etree.parse (full load, anchor) | 約386 MB | 大きさの基準を示すアンカー |
bounded モードでは、フルロード時の約386 MBに対して、ピークRSS差分はおよそ1〜2 MBに収まりました。これは0.3〜0.4%程度の差です。しかも最初の record event はファイルの読み込みが終わる前に発火するので、偽の“擬似streaming”ではなく、本当に段階的です。示唆的なのは中央の行です。同じ iterparse ループでも clear() を省くと、保持している参照のせいでメモリはまた約386 MBまで伸びます。勝ち筋は iterparse 単体ではなく、clear() にあります。フルロードのanchorがboundedモードよりはるかに高いことも、RSSメーターがちゃんと差を見分けている証拠です。(このメモリテストは、このパックで新たに実施したものです。footprint測定なので、借用した timing 数値とは別です。)
現場での意味は明快です。RAMに収まらない数ギガバイト級のXML exportを扱うなら、selectolax には逃げ道がありません。lxml の streaming parserを使うか、別の言語に行くかです。
名前空間:RSS、SVG、そしてデフォルト名前空間の罠
3つのnamespaceにまたがるRSS、デフォルトnamespaceと xlink を含む SVG、そしてデフォルトnamespaceのXMLを含めた12ケースを確認しました。12件すべて通過です。
lxml は RSS feed から //dc:creator/text() を正確に ["Alice", "Bob"] として取り出し、同一文書内の3つのnamespaceをまたいで //atom:link/@href と //content:encoded を解決し、SVG の2つ目のnamespaceで //s:rect や //s:use/@xlink:href を処理し、QName を使って Clark表記の {uri}local 名を分解し、nsmap で内省できます。これは保守され、文書化された挙動であり、selectolax が触れない次元です。selectolax はHTML5専用で、任意のXML namespaces を扱いません。
ただし、ひとつだけ覚えておくべき文書化済みの罠があります。XPath には「デフォルト namespace」という概念がありません。xmlns="urn:..." を宣言している文書に //book を当ててもヒットはゼロです。XPath では空のprefixが未定義だからです。これは lxml docs にも明記されています。人工的なprefixを割り当てる (namespaces={"c": "urn:..."} で //c:book とする。これで3件すべて見つかる) か、//*[local-name()='book'] に切り替える必要があります(こちらも3件)。バグではなく XPath仕様が忠実に実装されているだけです。ただ、誰もが一度は引っかかります。
実際の汚れたページでの忠実度:11件の実URLscrape
合成テストはきれいです。webはきれいではありません。selectolaxパックの fixture セットから実際に取得した11ページ(2026-07-10時点、読み取り専用)を再利用し、lxml.html を主役として通しました。
| Fixture | サイズ | Links | 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 |
11件すべてが lxml.html で解析でき、11件すべてでlink数・見出し数・画像数は selectolax パックの再利用済み lxml の数値と一致しました。照合結果は true です。この一致が、再利用が本当に同条件比較であって、同じラベルを貼った別物ではないことを示しています。
副次的に分かったのは、strict XML parserは11件中10件で例外を投げたということです。現実のWebページは、圧倒的に well-formed XML ではありません。だからこそ libxml2 の HTML recovery mode は、そうしたページを飲み込むために存在しています。例外だったのは BBC News で、Next.js でレンダリングされ、厳格なXMLでもなんとか通る程度に整っていました。HTML と書かれているからといって、必ずしも recovery が必要とは限りません。
ひとつ、数え方でつまずきやすい点があります。docs_python.html では、//a[@href](属性の存在を見る)だと343件でしたが、selectolaxパックの if n.get("href")(真偽値として評価する)では341件でした。差の2件は空の href="" リンクです。これは属性が存在するか、属性値が空でないかという、count規約の違いであって、lxml の挙動差ではありません。scrapingでは、空の href を数に入れるかどうかはparserではなく、filterの設計次第です。そこは知っておく価値があります。
バグに見えるけれどバグではない深さ制限
selectolaxパックでは、1,000階層や5,000階層の <div> ネストで lxml が最深部の内容を落とすことを記録しており、「lxml は最深部を静かに失う」とまとめていました。そこで私は仕組みを知りたくて、デフォルトparserと huge_tree=True を比較しました。

| 要求深度 | デフォルトパーサーの到達深度 | huge_tree=True の到達深度 |
|---|---|---|
| 300 | 253(以降を落とす) | 299(回復) |
| 1000 | 253(以降を落とす) | 999(回復) |
| 5000 | 253(以降を落とす) | 2045(まだ落ちる) |
デフォルトparserはおよそ253階層で打ち切り、それより深い部分を黙って捨てます。これはバグではありません。libxml2 による DoS 防御で、およそ256階層のネスト上限があり、敵対的な文書でstackが吹き飛ぶのを防いでいます。この点は XML_PARSE_HUGE に関する lxml launchpad thread でも説明されています。huge_tree=True を設定すると、300階層と1,000階層は完全に戻ります。ですが5,000階層では、huge_tree を有効にしても2,045階層までしか到達しません。つまり、設定可能な上限とは別に、libxml2 側にもっと硬い再帰上限があり、huge_tree ではそれは外れません。
したがって、実務の行動指針は具体的です。信頼できるソースから深いmarkupを解析するなら、lxml.html.HTMLParser(huge_tree=True) を使うこと。このパックが既存の観察に上乗せしたのは、仕組みが「data破損」ではなく「安全上の制限」であること、修正方法が huge_tree であること、そしてその修正でも届かない2段目の上限があることです。
DOMの読み書き、serializer、encoding
lxml は読み取り専用の抽出器ではなく、完全な読み書き可能な木構造です。編集面をケースごとに確認しました。8つの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" になります。
シリアライズは5項目すべて成功しました。XML と HTML モードでの tostring(HTML では void要素が自己閉じしないのが正しい)、pretty_print、C14N 正規化(method="c14n"、これも lxml 固有)、そしてきれいな round-trip きれいな round-trip です。
エンコーディングは、lxml が静かに差をつけるところです。"<p>café éè</p>".encode("latin-1") のような UTF-8 以外のバイト列を lxml.html.fromstring に食わせると、café éè をそのまま復元します。U+FFFD の置換文字もなく、byte落ちもありません。これは、selectolaxパックでの「クリーンな基準値」としての役割を、そのまま再現したものです。同じ入力は他の2engineでは静かに壊れていました(Lexbor は置換文字を出し、Modest はバイトを落としていました)。libxml2 に支えられた lxml の文字コード検出は、ここでは明らかに安定しています。
その反面、encodingをどう宣言するかには厳格です。XML宣言の encoding="latin-1" は XMLSyntaxError: Unsupported encoding: latin-1 を投げますが、IANA標準の encoding="ISO-8859-1" なら問題なく解析され、café が返ります。libxml2 はエイリアスではなく、正規化された名前しか受け付けません。これは launchpad #613302 にも昔から書かれている細かな点です。知らなければ面倒ですが、知ってしまえば単純です。
最後にnodeのライフサイクルです。3つの stale-handle シナリオを、それぞれ独立した subprocess で検証しました(もし本当に壊れるなら、非ゼロ終了で分かります)。ツリーが garbage collection された後にnodeを保持する、drop_tree() の後にハンドルを読む、remove() 後にnodeを使う──どれも segfault は起きませんでした。lxml は use-after-free を防ぐために、nodeからtreeへの参照を生かしたままにします。このテストで selectolax も同じく問題ありませんでした。
速度とメモリ(借り物だが、正直にそう書く)
このセクションの数値はすべて、2026-07-13時点の selectolaxパックからの再利用です。このパック自身は timing数値を一切出していません。そこは二度言ってでも明確にしておきます。
| 指標 | lxml の値 | 読み方 |
|---|---|---|
| 純粋な parse の p50 (10 MB) | 77.9 ms | selectolax-Lexbor より約33〜34%高速 |
| full parse + extract の p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | 小サイズでは Lexbor とほぼ互角 |
| 100kノード CSS スループット | 3,002,646 nodes/s | 3つのCエンジンの中で最速帯 |
| 10 MB の RSS 差分 | 128.9 MB | 6つのparserで最も軽量、BeautifulSoup より約1.7倍軽い |
| import のコールドスタート | 14.1 ms | parsel系 import より約2.3倍高速 |
純粋なparseとスループットの数値は強力で、lxml は計測した6つのparserの中で最もメモリ消費が少ないです。ただし、threadの話には注意書きが必要です。再利用したデータでは、4スレッドの wall-clock speedup は 1.21x にとどまり、評価は「inconclusive」でした。ですが、それは共有デフォルトparserの経路です。lxml FAQ には、各スレッドが自分専用のparser、もしくはコピーしたデフォルトparserを使う場合にのみ、parse中に GIL が解放されると明記されています。共有parserはアクセスを直列化します。正しいやり方のAPI面(XMLParser.copy() の存在、get / set_default_parser の存在、XPathEvaluator が内部ロックを持つこと)は構造的に確認しましたが、スレッドごとのparserにした場合の speedup は計測していません。新しい timing測定になるためですし、このパックはそこまで出していません。なので、1.21x は「素朴な共有経路での値」と読んでください。lxml のthread上限そのものではありません。
また、全体にひとつアスタリスクがあります。これらは単一プラットフォーム、macOS arm64 の数値です。lxml の pure parse が Lexbor より速いという主張は、一般に Lexbor backend のparserが最速だという通説とぶつかるので、本当に確定的と扱う前に Linux x86_64 で再確認したいところです。
ライセンス:地味だが大きい勝利
lxml は BSD-3-Clause ライセンスです。バンドルされる Cライブラリ、libxml2 と libxslt もどちらも MIT です。つまり、どこにも copyleft のない、完全に permissive な構成です。再配布を考えた瞬間に、この違いは効いてきます。対比すると、selectolax の wheel は LGPL-2.1 の Modest と Apache-2.0 の Lexbor を同梱しているので、クローズドな製品に組み込むなら lxml のほうがすっきりしています。
実務上のインストール面でも利点があります。lxml は libxml2 と libxslt を静的リンクした事前ビルド wheel を公開しているため、通常 pip install lxml だけでシステム側の libxml2 や compiler は不要です。sourceからbuildする場合とはかなり違う体験です。
lxml の役割、そして AI抽出レイヤーが引き継ぐ場所
ここで境界をはっきりさせておきます。ありがちなカテゴリ混同を避けたいからです。lxml は parserです。木構造を渡してくれて、query engine は非常に優秀です。ですが、その木の外側は今でもあなたの仕事です。page取得、JavaScript rendering、bot対策の回避、XPathの作成と保守、結果の整形。これはhostedな抽出サービスとは別の層であり、競合というより隣人に近い関係です。
取得・rendering・selection・保守のstackを自分で持ちたくない開発者にとって、その上位レイヤーにあるのが Thunderbit のようなものです。この読者にとって重要なのはbrowser extensionではなく、API、MCP server、CLIです。Thunderbit Open API では、POST /distill でページをきれいな Markdown に変換し、POST /extract で JSON Schema に基づいた structured data を取得できます。renderMode 切り替えと、大量処理向けの batch jobもあります。同じengineは MCP server としても使えます(thunderbit_suggest_fields、thunderbit_distill、thunderbit_extract)。agentやcoding assistant向けです。CLI としては、npx @thunderbit/thunderbit-cli で terminal から直接実行できます。JS rendering、bot対策、CAPTCHA を最初から処理し、schemaに一致したJSONを返します。つまり、parserの上位レイヤーであって、置き換えではありません。
整理はシンプルです。pipeline全体を自分で所有し、理解している木に対して外科的な XPath 制御が欲しいなら lxml を使う。selector や rendering の保守自体を持ちたくないなら、AI抽出APIを使う。実際には両方を使うsystemも多くあります。自分で管理できる構造化feedには lxml、扱いの難しいロングテールページには抽出service、という使い分けです。
このレビューで検証していないこと
これは暫定レビューであって、最終成績表ではありません。なので、未確認の点も明記しておきます。
timingとメモリの数値はすべて再利用済みで、単一プラットフォーム(macOS arm64、Python 3.14)です。そのパックの注意事項も引き継いでいます。つまり、「lxml は pure parse で速い」という結果は通説と逆なので、Linux x86_64 で再確認が必要です。スレッドごとのparserによる speedup は未検証です(新しい timing が必要)。iterparse のメモリは30万recordで測りましたが、GB級の実XMLでは測っていませんし、HTMLとXMLでの iterparse比較もしていません。長時間のsoak testもしていません。lxml の XSLT 1.0、RelaxNG / XMLSchema / DTD 検証、EXSLT 拡張はここでは完全に未検証です。機能としては大きいですが、今回の範囲はparseとselectionの核心までです。2,045で2つ目の深さ上限は観測しましたが、libxml2 の正確な再帰定数までは特定していません。検証したのは安定版 6.1.1 のみで、7.0.0 alpha は対象外です。Windows、source build、free-threaded 3.14t buildも未検証です。XPath の中でも、組み込みfunctionまでは見ましたが、XPath variable、カスタムPython extension function、事前コンパイルした etree.XPath オブジェクトの再利用は未確認です。
結論
lxml は新しい高速ツールではありません。だからこそ価値があります。これは20年物の libxml2 バインディングで、主流のPythonで競合できる実質的な XPath 1.0 エンジンを持ち、3段階の予測可能な厳格さと途中の error log、メモリに収まらない文書向けの本物の streaming parser、複数namespaceと encoding の正しい扱い、そして完全に permissive なlicenseを備えています。約253階層の深さ制限と共有parser時のthread数値という少し尖った点もありますが、それらは文書化され、設定可能で、いまでは仕組みも説明されました。
scraping pipelineを自分で持ち、XPath を前提にするなら、今でも lxml が第一候補です。selector や rendering を自分で保守したくないなら、Thunderbit API, MCP, CLI のような AI抽出レイヤーの出番です。競争ではなく、役割分担です。どちらにしても、これらの数値は暫定値として扱い、設計書に書く前に自分の環境で timing を再確認してください。
Webデータ抽出で Thunderbit を試す Get Started Free
よくある質問
lxml はウェブスクレイパーですか?
いいえ。lxml は parser兼serializerです。libxml2/libxslt へのPythonバインディングで、マークアップを編集・検索可能な木構造に変換します。page取得、JavaScript rendering、bot対策は行いません。request層は requests、httpx、headless browser、または scraping serviceで用意し、そのバイト列を lxml に渡します。
BeautifulSoup や selectolax ではなく、いつ lxml を使うべきですか? XPath が必要なら lxml です。BeautifulSoup は内部で lxml をbackend parserとして使えますが、ネイティブな XPath は持っていません。selectolax はCSS専用で、その狭い領域では速いです。selectionロジックにテキスト内容のfilter、親や祖先への移動、属性やtext nodeの抽出、件数ベースのpredicateが必要なら、それらを直接表現できる主流Pythonの選択肢は lxml の XPath engineだけです。
なぜ lxml は深いネストを静かに落とすのですか?
デフォルトparserは、ネストを約253階層で打ち切ります。これは敵対的な文書に対する libxml2 の DoS 防御であって、バグではありません。huge_tree=True(たとえば lxml.html.HTMLParser(huge_tree=True))を設定すると、300階層や1,000階層は完全に回復します。ただし、huge_tree でも外れない約2,045階層付近の、もうひとつ硬い再帰上限がある点には注意してください。
lxml はマルチスレッド解析で GIL を解放しますか? 条件付きです。lxml の FAQ では、各スレッドが自分専用のparser、またはコピーしたデフォルトparserを使う場合にのみ、解析中に GIL が解放されるとされています。共有parserを使うとアクセスは直列化されます。ここで再利用した 4スレッドの speedup 1.21x は、単純な共有parser経路の結果であり、スレッドごとのparserの場合の上限ではありません。後者はここでは計測していません。
2026年でも lxml はまだ保守されていますか? はい。安定版 6.1.1 は 2026-05-18 にリリースされ、リポジトリの最終 push は 2026-07-02 です。7.0.0 alpha も進行中です。GitHub Stars は約3,000あり、下層の libxml2 も活発に保守されているため、過去の遺物ではなく、現在進行形で使えるライブラリです。


