Skip to content

Requests vs. HTTPX: Choosing a Python HTTP Client

requests earned its reputation honestly. For a long time, if you needed to call an API, download a file, or glue two services together in Python, requests was usually the obvious answer.

Async web frameworks, high-concurrency services, and I/O-heavy workloads changed what many teams need from an HTTP client. That's where httpx starts to matter.

This post compares requests and httpx from the point of view of a Python developer who already knows requests, wants a clear explanation of what httpx adds, and doesn't want hand-wavy "modernization" advice.

The Champion: requests

requests became the standard because it removed friction. Small API. Sensible defaults. Session when you need reuse.

  • The API is small and easy to learn.
  • The defaults are simple enough for scripts and internal tools.
  • Session gives you connection reuse and shared configuration without much ceremony.
  • The ecosystem around it is huge: tutorials, examples, blog posts, Stack Overflow answers, and third-party integrations all assume you know requests.

For synchronous Python code, it's still a good library. A lot of production code doesn't need async support, doesn't need HTTP/2, and doesn't need a broader transport model. In those cases, requests remains a sensible choice.

That's why this comparison is not really about replacing a bad tool. It's about recognizing where the older tool stops fitting as cleanly.

The Challenger: httpx

httpx looks familiar on purpose. The synchronous API feels close enough to requests that most Python developers can read it immediately. That compatibility is one of its best features, because it lowers the cost of trying it.

Its two biggest advantages are:

  • Native async and await support through httpx.AsyncClient
  • Optional HTTP/2 support on both sync and async clients

Those aren't cosmetic differences.

Async support means httpx fits naturally into frameworks like FastAPI, Starlette, and any other async application that should not block the event loop on outbound network calls. HTTP/2 support means one client connection can handle multiplexed requests when the remote server supports it, which can matter for high-concurrency workloads.

You opt in with httpx.Client(http2=True) or httpx.AsyncClient(http2=True), and the server still has to support HTTP/2 for you to get it.

httpx also pushes users toward safer networking defaults. The biggest example is timeouts: requests doesn't time out unless you ask it to, while httpx enforces timeouts by default.

Head-to-Head Examples

The synchronous code looks almost identical.

A Standard Synchronous GET Request with requests

import requests

# A simple GET request with an explicit timeout.
response = requests.get("https://api.github.com", timeout=10)

# Raise an exception for 4xx or 5xx responses.
response.raise_for_status()

# Parse the JSON body into a Python dictionary.
data = response.json()
print(data["current_user_url"])

This is why requests became so popular. It's straightforward and readable.

The Same Synchronous GET Request with httpx

import httpx

# The synchronous API is intentionally close to requests.
response = httpx.get("https://api.github.com", timeout=10.0)

response.raise_for_status()

data = response.json()
print(data["current_user_url"])

For simple synchronous calls, the API is close enough that migration often starts with an import swap.

That doesn't mean behavior matches line for line. Redirect handling differs; see the Migration Guide.

An Asynchronous GET Request with httpx and asyncio

This is the point where httpx stops being a near-clone and starts doing work requests cannot do on its own.

import asyncio
import httpx


async def fetch_status(client: httpx.AsyncClient, url: str) -> tuple[str, int]:
    # The request is awaited instead of blocking the whole program.
    response = await client.get(url)
    response.raise_for_status()
    return url, response.status_code


async def main() -> None:
    urls = [
        "https://example.com",
        "https://foo.com/get",
        "https://api.github.com",
    ]

    # One AsyncClient can be reused across many requests.
    async with httpx.AsyncClient(timeout=10.0) as client:
        results = await asyncio.gather(
            *(fetch_status(client, url) for url in urls)
        )

    for url, status_code in results:
        print(f"{url}: {status_code}")


asyncio.run(main())

That example is doing concurrent I/O without spinning up a thread per request. For a small script, you may not care. For a service making dozens or hundreds of outbound calls, you usually do.

Under the Hood

The user-facing APIs look similar, but the concurrency model is not.

Concurrency: Thread Pools vs. Event Loops

requests is synchronous. Each request blocks the current thread until the network call finishes or fails. If you want concurrency around requests, the usual pattern is to add threads with concurrent.futures.ThreadPoolExecutor, a worker queue, or some higher-level framework.

from concurrent.futures import ThreadPoolExecutor

import requests


def fetch(url: str) -> int:
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.status_code


urls = ["https://example.com", "https://api.github.com"]

with ThreadPoolExecutor(max_workers=8) as pool:
    statuses = list(pool.map(fetch, urls))

It works. Sometimes it's the right call. It's also heavier than async I/O when you're waiting on the network more than you're computing.

httpx gives you both models:

  • httpx.Client for synchronous code
  • httpx.AsyncClient for async code running on an event loop

With the async client, the event loop can switch to other tasks while one request is waiting on the network. That usually means better scalability for I/O-bound workloads and a much cleaner fit inside async applications.

The short version:

Need Better Fit
Simple scripts, CLIs, cron jobs, internal tooling requests or sync httpx
Async web apps and high-concurrency outbound I/O httpx.AsyncClient
Existing synchronous codebase with no async stack requests stays reasonable

Timeouts: One Library Forces the Conversation

This is one of the most important practical differences.

requests doesn't set a timeout by default. If you forget timeout=..., the call can hang indefinitely.

import requests

# This can wait forever if the remote side stalls.
response = requests.get("https://example.com")

httpx takes the opposite approach. It includes timeouts for network operations by default and raises an exception after five seconds of network inactivity unless you configure something else.

import httpx

# HTTPX enforces timeouts by default.
response = httpx.get("https://example.com")
import httpx

try:
    httpx.get("https://example.com")
except httpx.TimeoutException:
    # Default inactivity timeout is five seconds unless you configure otherwise.
    raise

Migration Guide

If you already have a codebase built on requests, the good news is that the move to httpx is usually incremental rather than surgical.

Start with the Client Objects

The first mapping to remember is:

  • requests.Session() -> httpx.Client()
  • requests.get(...) -> httpx.get(...)
  • requests.post(...) -> httpx.post(...)

Example:

import requests

session = requests.Session()
session.headers.update({"User-Agent": "my-app/1.0"})

response = session.get("https://api.example.com/users", timeout=10)
response.raise_for_status()
import httpx

client = httpx.Client(headers={"User-Agent": "my-app/1.0"}, timeout=10.0)

response = client.get("https://api.example.com/users")
response.raise_for_status()
client.close()

In real code, prefer the context-manager form:

import httpx

with httpx.Client(headers={"User-Agent": "my-app/1.0"}, timeout=10.0) as client:
    response = client.get("https://api.example.com/users")
    response.raise_for_status()

Watch for Behavioral Differences

This is where migrations get real.

  1. Redirects requests follows redirects by default for most request methods. httpx doesn't. If your existing code assumes auto-follow behavior, use follow_redirects=True.
import httpx

with httpx.Client(follow_redirects=True) as client:
    response = client.get("https://example.com")
  1. Timeouts If your old requests code never set timeouts, moving to httpx may surface slow or broken dependencies that were already there. That's usually a good thing, but it can surprise you in the first round of tests.

  2. Sessions and clients If you already rely on requests.Session, moving to httpx.Client is straightforward. If you don't use sessions today, migration is also a good time to start. Reusing a client is better than creating a brand-new connection for every request.

  3. Async is not a search-and-replace Moving from requests to sync httpx is often simple. Moving to httpx.AsyncClient is a bigger change because the calling code has to become async too. That means async def, await, and usually an async-aware application boundary.

A Safe Upgrade Path

If you want the lowest-risk path:

  1. Replace requests with synchronous httpx in a small, well-tested slice of the codebase.
  2. Set explicit timeouts everywhere.
  3. Audit redirect assumptions.
  4. Move shared Session usage to httpx.Client.
  5. Introduce AsyncClient only where the surrounding code is already async or where the concurrency gain is worth the architectural change.

That sequence avoids turning a library swap into a full application rewrite.

So with that, my suggestions are straightforward:

  • Stick with requests when the codebase is synchronous and likely to stay that way, when you're writing scripts or internal tools and want a dependency everyone already knows, or when the current workload doesn't justify churn.

  • Reach for httpx when you're writing new code and want better timeout defaults, when you need async support, when you want one library for both sync and async calls, when HTTP/2 to the same host matters, or when your service fans out to a multitude of APIs per request and threads are piling up.

References