誰もが書く、あの定番のガードを置いてみます:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
これを、ステータス行とヘッダーはすぐ返すのに、本文の前で止まるサーバーに向けて実行してみてください。読み取りはタイムアウトしますが、このガードは発火しません。返ってくる例外は ConnectionError で、しかも ConnectionError は Timeout のサブクラスではありません。
同じ停止状況を httpx に当てると ReadTimeout が発生し、これは TimeoutException の派生なので、同等のガードでちゃんと拾えます。
スクレイパーを壊しやすい観点で httpx と requests の違いを調べ始めたとき、最初は async の話を書くつもりでした。ところが、async はこの比較でいちばん面白くない項目でした。
何を、どう検証したか
ローカルのフィクスチャサーバーに対して 8 つのプローブを実施しました。クライアントが「何をしたか」と報告しても、それは実際に起きたことの証拠にはならないからです。サーバー側では TCP 接続数 を数えています。これは、受け付けたソケットごとに 1 回だけ増え、リクエストラインが解析される前に記録されます。また、実際に取得されたパス も数えます。接続の再利用とリダイレクト追従は、どちらもワイヤ上の挙動についての主張であり、その確認もワイヤ上で行うべきだからです。
httpx は 0.28.1 の http2 extras 付き、requests は 2.34.2、Python は 3.14.2、環境は macOS arm64。どちらも新しい virtualenv に入れ、片方の状態がもう片方へ混ざらないようにしました。生データ: httpx-probes.json。
最初の実行の前に 6 件の予測をハーネスへ入れ、その後も固定しました。3 件は的中、2 件は外れ、1 件は「自分が思い浮かべたケース」には合っていたものの、重要なケースを外していました。集計は prediction-scorecard.json にあります。
使ってみると変わるデフォルト

| 挙動 | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| デフォルトでリダイレクトを追う | はい | いいえ |
| モジュールレベルの呼び出しで接続を再利用する | いいえ | いいえ |
| ヘッダー前に停止 | ReadTimeout | ReadTimeout |
| 本文途中で停止 | ConnectionError | ReadTimeout |
| charset 未指定 | ISO-8859-1 | utf-8 |
| HTTP/2 | 利用不可 | 任意、利用可 |
| connect / read / write / pool を個別にタイムアウト指定 | いいえ | はい |
リダイレクト、ソケット再利用、例外結果、デコード、プロトコル交渉はプローブで観測しました。タイムアウト API の形状と、requests に HTTP/2 フラグがないことは、API 機能としての観測です。 httpx-probes.json。
この表のうち 3 行は、切り替えたその日から静かにコードの挙動を変えます。
リダイレクト: デフォルトでは無効。サーバーがそれを証明する
/ok で終わる 4 段のリダイレクトチェーン:
| クライアント | サーバーが観測した回数 | 返されたステータス |
|---|---|---|
| requests | 5 リクエスト | 200 |
| httpx | 1 リクエスト | 302 |
httpx, follow_redirects=True | 5 リクエスト | 200 |
5 というのは、4 ホップに最終到達先を足した数です。私の予測は 4 でしたが、単純な計算を確認していませんでした。論点は方向性であり、数は本文中でこっそり直すのではなく、ここで訂正しています。
これは httpx の文書化された挙動で、設計としても筋が通っています。リダイレクトは、呼び出し側が知りたい場合があるからです。ただし、移行時に何もエラーを出さず壊れる原因としては、これが最有力です。コードは 302 を受け取り、response.text は空、パーサーは行を見つけられず、ログは 200 OK だと思っている……のですが、実際には 302 が返っていて、requests のときは見る必要がなかったので誰もステータスコードを見ていませんでした。
タイムアウトに関する、私が逆に予想していた点
私は、httpx は失敗したフェーズを明確に名指しし、requests は両者をひとつのクラスにまとめるだろうと予測していました。実際は逆でした。
公式リファレンス: Requests timeout documentation.

公式リファレンス: HTTPX timeout documentation.
| 停止タイミング | requests | httpx |
|---|---|---|
| ステータス行の前 | ReadTimeout | ReadTimeout |
| 本文途中、ヘッダー送信後 | ConnectionError | ReadTimeout |
httpx は、どちらのケースにも同じく正確な名前を返します。requests は分けます。そして、その境界がちょうどリトライコードの分岐条件になっています。
これはクラス階層からの推論ではありません。実際にガードを走らせました:
| 停止タイミング | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| ステータス行の前 | 捕捉する | 捕捉する |
| 本文途中 | ConnectionError としてすり抜ける | 捕捉する |
timeout-retry-guard.json。 requests.exceptions.ConnectionError は requests.exceptions.Timeout のサブクラスではなく、httpx.ReadTimeout は httpx.TimeoutException のサブクラスです。
requests の例外メッセージには、ConnectionError の中で Read timed out. と出ます。ライブラリは何が起きたかを知っています。ですが、その情報を型システムへは伝えません。そして、あなたの except は型システムを参照しているのです。
今回測ったのは、ヘッダーは届いたが、その後の本文の進行が読み取りタイムアウトを超えるほど止まったケースです。タイムアウトの範囲内でチャンクを送り続けるレスポンス、たとえば意図的なストリーミングは別の挙動になり得ますが、今回は検証していません。
接続プーリング: 違いはクライアント API にある
GET を 10 回、4 通りで実施し、サーバー側でソケット数を数えました:
| 方法 | 開かれたソケット数 |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
これは同じ結果であり、わざわざ書く価値があります。なぜなら、この組み合わせに関して最も広く流通している通念が、「httpx はプールするが requests はしない」だからです。実際には、どちらもモジュールレベルではプールしません。プールするのはクライアントオブジェクト経由です。今 requests.get() をループで回しているなら、httpx.get() に置き換えても、ループのままではソケットの使い回しは変わりません。

HTTP/2 は明示的指定で、extra が必要
アーティファクトに記録した公開 HTTP/2 エンドポイント 1 つに対して:
公式リファレンス: RFC 9113: HTTP/2.
| クライアント | ネゴシエート結果 |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1、フラグなし |
httpx[http2] の extra が必要です。私は、単純に pip install httpx すれば、何も言わずに 1.1 を使うクライアントになるだろうと想定していました。書き留める前に確認したところ、次のように出ました:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
これは 1 件もリクエストを送る前、Client の構築時点で例外を投げ、しかも修正方法までメッセージに書いてあります。こういう失敗は、こちらにとって「良い失敗」です。私はその逆だと思い込んでいました (http2-extra-missing.json)。
このプローブで示せるのは、そのエンドポイントでプロトコル交渉に成功したことまでです。スクレイピング速度の優位性までは示していません。対応する HTTP/1.1 のワークロードは試していません。
シーケンシャルと並行実行のフィクスチャ処理速度
0.3 秒 sleep するエンドポイントに対して 20 リクエスト:
| モード | 経過時間 | ソケット数 |
|---|---|---|
Sync, 1 つの Client | 6.138 秒 | 1 |
Async, 1 つの AsyncClient | 0.357 秒 | 20 |
並行実行のケースは 0.357 秒で終わり、逐次実行の 6.138 秒よりかなり速く見えました。ただし、同期クライアントは 1 本の接続を再利用したのに対し、並行版は 20 本の接続を開いています。つまり、この実験はライブラリの速度というより、実行モデルと実効並行性の違いを測っています。
この数値の見方として、これが正直です。これは意図的に遅いエンドポイントに対する並行性の測定であって、httpx 自体の性能比較ではありません。async の仕組みが使えるクライアントなら、どれも同じ近辺に入りますし、速いエンドポイント相手なら差は小さくなります。
予想していなかった charset のケース
私は、ヘッダーが嘘をつくケース、つまり charset=iso-8859-1 と書かれているのに実体は utf-8 バイト列、では両方とも同じ文字化けになるだろうと予測していました。実際、その通りです。どちらも、元の Café Ubersetzung — naïve résumé に対して Café Ubersetzung â naïve résumé を返しました。
ただし、私が予想していなかった、そして重要なケースは別です:
| レスポンス | requests のデコード | httpx のデコード |
|---|---|---|
charset=utf-8、utf-8 バイト列 | 正しい | 正しい |
charset=iso-8859-1、utf-8 バイト列 | 文字化け | 文字化け |
| charset 指定なし | 文字化け | 正しい |
requests はヘッダーに何も書かれていないと ISO-8859-1 にフォールバックし、httpx は utf-8 を既定にします。そのため charset が欠けたフィクスチャでは、.text を通したデコード結果が両者で異なりました。response.content を使う側は、どちらも同じ元のバイト列を保持します。
メモリ使用量。測るのは簡単だから
ピーク RSS、/usr/bin/time -l、各セルごとに新しいプロセスを 1 つ起動:
| セル | requests | httpx |
|---|---|---|
| import のみ | 36.0 MiB | 30.6 MiB |
| import + GET 1 回 | 35.8 MiB | 40.7 MiB |
これは単発のスナップショットであり、requests の「1 回 GET」値が import のみよりわずかに小さいこと自体が、実行ノイズを示しています。ここから方向性のあるメモリ結論は導けません。繰り返しサンプルとレンジが必要です。
どう選ぶか
既存の requests コードのバグを探しているなら? リダイレクトの前提、本文途中の停止まで拾えると思って requests.exceptions.Timeout だけを捕まえているハンドラ、そして charset なしのレスポンスを .text で受ける処理を見直してください。
機械的に httpx へ移行するなら? 例外の名前空間を httpx.TimeoutException か、より狭いフェーズ別クラスへ変え、follow_redirects を有効にするか決め、デコード前提を再テストしてください。すでに示した本文途中のケースは、既存の requests ハンドラが取り逃がしています。移行によってその特定のバグが新たに生まれるわけではありません。
新しく、多数の URL を取りにいくものを作るなら? AsyncClient と、connect / read / write / pool を分けたタイムアウトが必要なら httpx は有力候補です。それらのフェーズは、どこで待たされたかを示します。接続確立、レスポンス本文の進行、リクエスト送信、ローカルのプール待ちです。遠隔ホストがなぜそう振る舞ったかまでは教えてくれません。
小さくて同期的なものを書くなら? requests で十分ですし、どこにでもあります。移る理由は速度ではありません。
何を選ぶにしても、 モジュールレベル関数ではなくクライアントオブジェクトを使ってください。今回の一覧で、両方のライブラリに対して素直に得をする変更は、それだけです。
マネージド API の出番
ここまでの話はすべて取得レイヤーの話であり、そのレイヤーは簡単な部類です。JavaScript はレンダリングしませんし、アンチボットのチャレンジも処理しません。欲しかった行データへ HTML を変換することもありません。
著者注: Thunderbit は、URL を渡してレンダリングと抽出まで任せる当社のマネージド विकल्पです。この HTTP クライアントのハーネスでは未検証です。検討すべきなのは、HTTP クライアントの意味論ではなく、ページ取得や構造化抽出そのものが問題のときだけです。
普通のページを取得して自分で解析するのであれば、どちらのクライアントも選択肢に入ります。マネージドサービスは、買うか作るかの別問題であって、これらのライブラリを比較する証拠ではありません。
より広い比較としては、web scraping API roundup でホスト型の選択肢を、open-source scraper pillar でセルフホスト型の選択肢を扱っています。
結論
async の並行処理、フェーズ別タイムアウト、UTF-8 フォールバックが必要な新しい Python の取得レイヤーなら、今回の条件では httpx を既定にします。成熟した同期コードで、移行リスクがその利点を上回るなら requests も十分実用的です。プロキシ、リトライ方針、TLS フィンガープリンティング、ストリーミング、アップロード、そして現実的なネットワーク変動は試していないので、これはスクレイピングクライアント全体の万能ランキングではありません。
注意すべき理由はリダイレクトのデフォルトであり、しかもそれが本当に危険なのは、良い設計判断だからこそです。明示は暗黙に勝ちます。ただし、暗黙だったものが、すでに出荷済みのコードでは土台を支えていた、という瞬間までは、です。
事前登録したスコアカードは、最終的に 3 件の正解、2 件の不正解、1 件の未完了予測で終わりました。実務上いちばん役立った訂正は本文途中の例外クラスでした。残りの判断は、スコアカードの物語ではなく、観測された挙動から行うべきです。
Web データ抽出に Thunderbit を試す Get Started Free
FAQ
httpx は本当にリダイレクトを追わないのですか?
デフォルトでは追いません。サーバーは 4 段のチェーンに対して 1 回しかリクエストを数えず、返答は 302 でした。各呼び出しで follow_redirects=True を渡すか、Client に一度だけ設定してください。これは文書化され、意図された動作です。それでも移行時に静かに壊れやすいのは、失敗が例外ではなく空のパースとして現れるからです。
except requests.exceptions.Timeout だけでは本当に足りないのですか?
ヘッダー送信後に止まるサーバー相手では足りません。そのケースは ConnectionError を投げ、Timeout のサブクラスではないため、ガードが取り逃がします。これは推論ではなく、直接実証した結果です。両方を拾いたいなら requests.exceptions.RequestException を捕まえてください。ただし、それはタイムアウト以外も拾うことになります。
httpx は requests より速いのですか? 1 リクエストずつの比較では、意味のある差はありません。そもそもそのためのものではないからです。このテストでの 17.2 倍は、0.3 秒のエンドポイントに対して 20 件を同時に送った結果であり、並行性の測定です。ワークロードが逐次なら、速度向上は期待せず、デフォルト設定を基準に選んでください。
http2 の extra は必要ですか?
HTTP/2 を使いたい場合だけ必要です。http2=True を設定しているのに入っていないと、Client 構築時に httpx は ImportError を投げ、httpx[http2] を入れるよう案内します。静かに 1.1 に落ちる心配はありません。私はそうなると思い込んでいて、書く前に確認しました。
ここで検証していないものは何ですか? プロキシ挙動です。スクレイピングでは非常に重要で、別のハーネスが必要です。リトライです。httpx にはリトライロジックがなく、requests は urllib3 からそれを受け取るので、正確には 2 つのリトライライブラリの比較になります。TLS フィンガープリンティングです。アンチボットが実際に見るのはこの軸ですが、どちらのライブラリもそこには対応していません。ストリーミングとファイルアップロードです。さらに、ここでの 8 プローブのうち 6 つは localhost で、1 台のマシン、1 つの Python バージョンで実行しています。フィクスチャサーバー由来のレイテンシ数値は、ネットワークではなく設計の測定です。


