Pydantic HTTPX2 - What's new and how to take proper advantage of it?

Pydantic HTTPX2 - What's new and how to take proper advantage of it?

by: Manuel ⏱️ 📖 14 min read 💬 0

If you've written Python for any length of time, you've used requests. It's the de facto standard, the friendly library that taught a generation of developers that HTTP didn't have to be painful. It's also been mostly frozen for years, sync-only, and missing every modern HTTP feature you can think of.

Enter httpx, the modern successor that quietly took over under the hood of everything from FastAPI's test client to the OpenAI SDK. And now, in 2026, Pydantic Services has picked up the stewardship of that library under a new name: HTTPX2.

This is a big deal, and not just because it has a slick new docs page. It's a signal about the maturity of the Python ecosystem and a reminder that the library you reach for to do HTTP calls deserves more thought than "I'll just pip install requests."

In this article, I'll cover what HTTPX2 actually is, why it matters, what makes it different from both requests and the other modern alternatives, and how to take proper advantage of it in your own code.

So what is HTTPX2?

Let's start with the most important clarification: HTTPX2 is not a different library. It's the same httpx you may already know, now formally maintained by Pydantic Services under the new package name httpx2. The "2" is a versioning marker for the new stewardship, not a fundamental rewrite.

The backstory is short but important. The original httpx project, while widely adopted, had seen reduced maintenance activity. Given how critical it is, sitting in the request path of countless production systems, that's a problem. Pydantic Services stepped in to provide what the announcement calls "a reliably maintained path forward, including timely security updates."

In plain English: somebody competent is now responsible for a library a lot of us depend on, and they have real reasons to keep it healthy (Pydantic uses it themselves in their AI products).

So when I say "HTTPX2" in this article, I'm talking about the library you may already know as httpx. The code, the design, and the API all carry over. What changes is the package name on PyPI (httpx2), the import statement (import httpx2), and who's responsible for keeping it healthy.

Why HTTPX (and now HTTPX2) exists at all

To understand why this library matters, you need to understand what requests doesn't do.

requests was created in 2011 by Kenneth Reitz. It's a great library, and its API was so well-designed that almost every Python HTTP client since has tried to copy it. But it was designed in a world where:

  • HTTP/2 didn't exist as a deployed standard
  • Async Python was barely a thing
  • Type hints weren't part of the language
  • The notion of "make a request to your own ASGI app for testing" was nonsense

Today, all four of those constraints are reversed. And requests, by design, won't fix them. The maintainers have been very clear that the library is in maintenance mode by choice.

HTTPX2 sits exactly where requests used to: it's the "first HTTP client you reach for" in modern Python. But it does it with:

  • Both synchronous and asynchronous APIs in the same library
  • HTTP/1.1 and HTTP/2 protocol support
  • Full type annotations so your IDE actually helps you
  • Default timeouts everywhere (no more hanging forever on a dead socket)
  • The ability to call WSGI and ASGI applications directly without going over the network

That last one is the trick FastAPI's TestClient uses to give you very fast tests. You're not really hitting localhost; you're calling into the app in-process, but the request and response objects look exactly like a real HTTP exchange.

The biggest advantages, with code

Let me show you what this looks like in practice. Most of these will be familiar if you've used requests, because the API is deliberately compatible. That's the whole point.

Same shape as requests, more capability

The simplest case is a drop-in. Where you used to write:

import requests

response = requests.get("https://api.example.com/users")
response.raise_for_status()
data = response.json()

You can now write:

import httpx2

response = httpx2.get("https://api.example.com/users")
response.raise_for_status()
data = response.json()

That's it. Same method, same response object, same JSON helper, same raise_for_status(). If your project's HTTP usage is all this shape, the migration is literally import httpx2 as requests at the top of your files (please don't actually do that in production, but it works).

Async without changing libraries

Here's where things get interesting. The same library does async, in the same module, with the same response shape:

import asyncio
import httpx2

async def fetch_users():
    async with httpx2.AsyncClient() as client:
        response = await client.get("https://api.example.com/users")
        response.raise_for_status()
        return response.json()

users = asyncio.run(fetch_users())

Compare that to the requests world, where async means switching to aiohttp, which has a completely different API, different response objects, different exception types, and different mental model. With HTTPX, you can mix sync and async in the same codebase without ever switching libraries.

Real connection pooling with clients

One of the biggest mistakes I see in Python HTTP code, even from experienced developers, is calling requests.get() over and over in a hot loop. Every call opens a fresh connection, does the TLS handshake, and tears it down. On any non-trivial workload, this is wasteful.

The right answer in both requests and httpx2 is to use a session/client. But httpx2 makes it more explicit and harder to get wrong:

import httpx2

with httpx2.Client(base_url="https://api.example.com", timeout=10.0) as client:
    user = client.get("/users/42").json()
    posts = client.get("/users/42/posts").json()
    # connection is reused across both calls

The Client (and AsyncClient) holds the connection pool, the cookies, the timeouts, the base URL, and any auth or headers you configure once. You instantiate it once per logical "thing you're talking to," and you reuse it for the lifetime of that thing.

If you're inside a FastAPI app, your client should be a long-lived object on the app state. If you're in a CLI, it lives for the duration of the command. If you're in a worker, it lives for the duration of the task batch. The thing you should not do is create a new httpx2.Client() for every single call.

HTTP/2 with one flag

HTTP/2 isn't just a buzzword. Real benefits include multiplexing (multiple requests over one connection, no head-of-line blocking), header compression, and server push. To enable it in HTTPX, you pass one argument:

import httpx2

with httpx2.Client(http2=True) as client:
    response = client.get("https://example.com/")
    print(response.http_version)  # "HTTP/2"

You'll need the h2 extra installed (pip install "httpx2[http2]"), and the server has to support HTTP/2 (most do over HTTPS today). When both sides agree, you get the protocol upgrade transparently. When they don't, you fall back to HTTP/1.1.

requests simply can't do this. There's no path. You'd have to switch to a different library entirely.

Default timeouts that actually save you

This one is small but enormously important. Out of the box, HTTPX has a 5-second default timeout on every network operation. If a connection hangs, if a server stops responding mid-stream, if anything goes wrong, your code raises a TimeoutException instead of hanging your worker forever.

By contrast, requests defaults to no timeout. If you don't pass timeout= explicitly, your call can block indefinitely. I've seen production outages caused by exactly this, a single unresponsive upstream silently consuming an entire process pool.

You can configure the HTTPX default to whatever you want:

import httpx2

# Five-second connect, ten-second read, ten-second write, five-second pool wait
timeout = httpx2.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0)

with httpx2.Client(timeout=timeout) as client:
    response = client.get("https://api.example.com/slow")

The fine-grained breakdown matters. "Five seconds" usually meant different things to different libraries. HTTPX makes the four phases explicit so you can tune each one.

Streaming, properly

Reading a large file or processing a long-lived stream is one of those things that looks easy until you run out of memory. HTTPX handles it with a stream context manager:

with httpx2.stream("GET", "https://example.com/large-file.zip") as response:
    with open("output.zip", "wb") as f:
        for chunk in response.iter_bytes():
            f.write(chunk)

The body is never fully buffered in memory. You can iterate by bytes, lines, or text, and the same API exists on the async side as aiter_bytes(), aiter_lines(), aiter_text().

Testing your own ASGI/WSGI app

This is the feature most people don't know about, but it's quietly transformative. You can point HTTPX at your own FastAPI app, Flask app, or any ASGI/WSGI application, and it'll handle the request in process, no network round-trip:

from fastapi import FastAPI
import httpx2

app = FastAPI()

@app.get("/ping")
def ping():
    return {"status": "ok"}

# In a test:
transport = httpx2.ASGITransport(app=app)
with httpx2.Client(transport=transport, base_url="http://test") as client:
    response = client.get("/ping")
    assert response.json() == {"status": "ok"}

This is exactly what FastAPI's built-in TestClient does under the hood. The advantage: your tests are dramatically faster, deterministic, and don't need a running server. The disadvantage: you're not exercising the network stack, so it's not a full integration test. For most unit and route-level tests, that's a great trade.

Let's look at the landscape

HTTPX is not the only option. To give you a real recommendation, I have to lay out the alternatives fairly.

requests

Still the most installed package on PyPI. Still completely fine for sync-only, low-concurrency, "I just need to call this API once" scenarios. The API is well-known and widely copied. The maintenance is minimal. There is no path to async, HTTP/2, or HTTP/3 from inside this library, and the maintainers have publicly said it will stay that way.

Use it when: you have a short script, no async needs, no performance needs, and no plans to grow the scope. Or when you're maintaining old code where switching isn't worth the churn.

aiohttp

The other big async library. Older than HTTPX in the async space, and it includes both a client and a server. The client is fast, especially under heavy concurrent load, where benchmarks regularly show it edging out HTTPX. The API is different from requests, which means learning a new mental model.

Use it when: you have a very async-heavy workload, you need the absolute best concurrent throughput, or you're already in the aiohttp ecosystem (e.g., your server is also aiohttp-based).

niquests

A newer, less famous option that deserves to be on your radar. niquests is a fork of requests itself, designed to be a near-drop-in replacement that adds modern features: HTTP/2, HTTP/3 (over QUIC), async support, and more. Migration is genuinely import niquests as requests, and benchmarks show it as one of the fastest options out there.

Use it when: you need HTTP/3 specifically, you want the requests API exactly, and you're comfortable with a smaller community and ecosystem than HTTPX.

urllib3

The transport layer under requests. Low-level, very reliable, used by basically everything. You almost never use it directly, but it's good to know it's there. If you're writing your own client library and want full control, urllib3 is the foundation.

Use it when: you're building infrastructure, not application code. Or when you need very specific behavior that higher-level libraries don't expose.

httpcore

The transport layer under httpx2, in the same way urllib3 sits under requests. Same advice applies: you don't use it directly unless you're doing something very specific.

urllib (stdlib)

The standard library option. Works without a dependency, which is occasionally valuable for tiny scripts or constrained environments. The API is genuinely unpleasant for anything more than the most basic GET. Skip it unless you have a specific reason to avoid dependencies entirely.

Honorable mentions

  • grequests: requests + gevent. Legacy. Avoid for new code.
  • treq: requests-like API on Twisted. Niche, Twisted-specific.

When to pick what

Here's the decision framework:

Scenario Pick
New project, modern Python, may need async later HTTPX2
You're already using httpx and want maintenance guarantees HTTPX2 (it's the same thing)
Quick script, no async, no concurrency requests is still fine
Pure async, very high concurrency, you don't mind a different API aiohttp
You absolutely need HTTP/3 / QUIC right now niquests
FastAPI test client HTTPX2 (already what FastAPI uses)
Writing an SDK for someone else to consume HTTPX2
Building a CLI that calls a REST API HTTPX2
Maintaining a legacy requests-based codebase Stay on requests unless you have a reason

The default answer for most new code in 2026 is HTTPX2. The reasons are simple: same ergonomics as requests, modern features when you need them, a real maintainer who isn't going anywhere, and a track record of being trusted in places like FastAPI and the OpenAI SDK.

Using HTTPX2 well

If you're going to use HTTPX2, here are the patterns that separate "I just replaced requests" from "I actually use this library well."

Use a Client. Always.

I said this earlier but it bears repeating because I see this mistake constantly. Don't do this:

# Inefficient: new connection every call
for user_id in user_ids:
    response = httpx2.get(f"https://api.example.com/users/{user_id}")
    process(response.json())

Do this:

with httpx2.Client(base_url="https://api.example.com", timeout=10.0) as client:
    for user_id in user_ids:
        response = client.get(f"/users/{user_id}")
        process(response.json())

The second version reuses the TCP connection, the TLS session, and the connection pool. On any non-trivial number of calls, this is the difference between "fast enough" and "painfully slow."

Configure timeouts deliberately

Don't accept the defaults if you have any opinion about how long things should take. Pick numbers that match your actual SLA:

timeout = httpx2.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)

If you're talking to a slow upstream, raise the read timeout. If you're calling something that should be instant, lower it. Vague timeouts are the source of countless production incidents.

Use event hooks for cross-cutting concerns

HTTPX has built-in hooks for things like logging every request, adding correlation IDs, or implementing custom retry behavior:

def log_request(request):
    print(f"--> {request.method} {request.url}")

def log_response(response):
    print(f"<-- {response.status_code} {response.url}")

client = httpx2.Client(
    event_hooks={"request": [log_request], "response": [log_response]},
)

This is much cleaner than scattering log statements through every call site. The same hooks work on the async client.

Lean on the typed API

Because HTTPX is fully type-annotated, your IDE and type checker will catch a lot of mistakes that requests lets through. Use the actual return types and exceptions rather than catching Exception:

try:
    response = client.get("/users/42")
    response.raise_for_status()
except httpx2.TimeoutException:
    # Specifically a timeout
    ...
except httpx2.HTTPStatusError as e:
    # Got a response, but it was 4xx/5xx
    print(e.response.status_code)
except httpx2.RequestError:
    # Network-level failure
    ...

Specific exception types let you handle different failure modes appropriately. A timeout might warrant a retry; a 404 probably doesn't.

Don't reach for async unless you need it

Async is a tool, not a default. If your code makes one or two HTTP calls per request, sync HTTPX is fine, and probably easier to debug. Async pays off when you have many independent calls that can be fired in parallel:

async def fetch_many(urls):
    async with httpx2.AsyncClient() as client:
        tasks = [client.get(url) for url in urls]
        return await asyncio.gather(*tasks)

That's a real win. Wrapping a single sequential workflow in async just to use AsyncClient is overhead with no benefit.

Migration from requests, the practical checklist

If you're actually doing this in a real codebase, here's the order I'd go in:

  1. Add httpx2 as a dependency. Don't remove requests yet; let them coexist.
  2. Find your hottest path, the one that makes the most outbound HTTP calls. Migrate that first.
  3. Introduce a Client instance as a long-lived object in that path. This alone often gives noticeable wins because of connection pooling.
  4. Set explicit timeouts. Pick numbers that match what your system actually needs.
  5. Replace requests.exceptions.* catches with httpx2.* equivalents. The hierarchies are slightly different.
  6. Run your tests. Most things "just work" because of API compatibility, but watch for edge cases around redirects, retries, and connection errors.
  7. Repeat for the next path. Don't try to migrate everything at once.
  8. Once everything's migrated, remove requests from your dependencies.

If you're building greenfield, none of this applies. Just start with HTTPX2 and you'll thank yourself later.

Final Thoughts

HTTPX2 isn't an exciting new library, and that's exactly why it matters. It's the boring, reliable foundation Python has been quietly converging on for years, now with a maintainer who has both the capability and the motivation to keep it healthy. The fact that Pydantic Services is investing in it is a useful signal that the people building serious Python tooling consider HTTPX a critical piece of the ecosystem.

If you're picking an HTTP client today, this is the one I'd pick. The API is familiar if you know requests, the features are genuinely modern, and the maintenance story is, finally, predictable. Take advantage of the client lifecycle, the timeouts, and the typed exceptions, and your HTTP code will be measurably better than the requests-shaped code most of us grew up writing.

And next time someone asks you "should I use requests?", the answer is no longer automatic. The answer is "if it's a tiny script, sure; otherwise, take a serious look at HTTPX2."

Photo by Jan Tinneberg on Unsplash

Comments

💬

No comments yet

Be the first to share your thoughts on this article!

Leave a Comment

All comments are reviewed for spam before being displayed 5000 left
Replying to