ZetCode

Python httpx

last modified October 30, 2026 Python 3.15

In this article we show how to create HTTP requests in Python with the httpx module. The httpx module allows to create both synchronous and asynchronous HTTP requests.

The httpx module

HTTPX is an HTTP client for Python, which provides sync and async APIs, and support for both HTTP/1.1 and HTTP/2. It has an API similar to the popular requests library. Unlike requests, httpx has timeouts enabled by default and does not follow redirects unless asked to.

With the combination of httpx and the asyncio module, we can generate asynchronous web requests. This may lead to a considerable increase of efficiency in programs that send many requests.

In the examples, we use httpbin.org, a freely available HTTP request and response service, and example.com, a small HTML page reserved for documentation examples. The HTTP/2 support requires the optional http2 extra.

$ pip install 'httpx[http2]'

Python httpx status code

In the first example, we determine the status of a web page. The status code is determined with the status_code property.

sync_status.py
import httpx

r = httpx.head('https://example.com')

print(r.status_code)
print(r.reason_phrase)
print(r.http_version)
print(r.is_success)

The example creates a synchronous HEAD request to the example.com website and retrieves an HTTP response. A HEAD request returns only the headers, without the body. From the response, we get the status code, the reason phrase, and the HTTP version. The is_success property is True for 2xx status codes.

$ python sync_status.py
200
OK
HTTP/1.1
True

Python httpx GET request

The following example creates a synchronous GET request.

sync_get.py
import httpx

r = httpx.get('https://example.com')

print(r.status_code)
print(r.headers['content-type'])
print(r.encoding)
print(len(r.content), 'bytes')

start = r.text.index('<title>') + len('<title>')
end = r.text.index('</title>')
print(r.text[start:end])

We generate a GET request to a web page with the httpx.get function. The content property contains the raw body as bytes; the text property contains the body decoded with the encoding from the Content-Type header. We print the length of the document and its title.

$ python sync_get.py
200
text/html; charset=utf-8
utf-8
577 bytes
Example Domain

Python httpx query parameters

The params option sends query parameters with the request.

sync_query_params.py
import httpx

payload = {'name': 'John Doe', 'occupation': 'gardener'}
r = httpx.get('https://httpbin.org/get', params=payload)

print(r.url)

data = r.json()
print(data['args'])
print(data['headers']['User-Agent'])

The parameters are encoded into the query string of the URL. The json method decodes the JSON body of the response. httpbin.org returns the received parameters in the args field and the request headers in the headers field.

$ python sync_query_params.py
https://httpbin.org/get?name=John+Doe&occupation=gardener
{'name': 'John Doe', 'occupation': 'gardener'}
python-httpx/0.28.1

Python httpx POST form request

A POST request is generated with the httpx.post function. With the application/x-www-form-urlencoded content type, the data is sent in the body of the request; the keys and values are encoded in key-value pairs separated by &, with a = between the key and the value.

sync_post_form.py
import httpx

payload = {'name': 'John Doe', 'occupation': 'gardener'}
r = httpx.post('https://httpbin.org/post', data=payload)

data = r.json()
print(data['form'])
print(data['headers']['Content-Type'])

We generate a synchronous POST request with form data to httpbin.org/post. The payload is set to the data option.

$ python sync_post_form.py
{'name': 'John Doe', 'occupation': 'gardener'}
application/x-www-form-urlencoded

Python httpx POST JSON request

The json option sends JSON data.

sync_post_json.py
import httpx

payload = {'int': 123, 'boolean': True, 'list': ['a', 'b', 'c']}
r = httpx.post('https://httpbin.org/post', json=payload)

data = r.json()
print(data['json'])
print(data['headers']['Content-Type'])

The Python dictionary is serialized to JSON and the application/json content type is set. httpbin.org returns the parsed data in the json field, with the keys sorted alphabetically.

$ python sync_post_json.py
{'boolean': True, 'int': 123, 'list': ['a', 'b', 'c']}
application/json

Python httpx request headers

Custom request headers are passed in the headers option.

headers.py
import httpx

headers = {'User-Agent': 'zetcode-client/1.0', 'Accept': 'application/json'}
r = httpx.get('https://httpbin.org/headers', headers=headers)

for name, value in r.json()['headers'].items():
    if not name.startswith('X-Amzn'):
        print(f'{name}: {value}')

We set a custom user agent and the accepted content type. The httpbin.org/headers endpoint returns the headers it received; we skip the tracing headers added by the load balancer.

$ python headers.py
Accept: application/json
Accept-Encoding: gzip, deflate
Host: httpbin.org
User-Agent: zetcode-client/1.0

Python httpx Client

The top-level functions such as httpx.get open a new connection for each request. The httpx.Client reuses connections and shares configuration among requests.

client.py
import httpx

with httpx.Client(base_url='https://httpbin.org',
                  headers={'User-Agent': 'zetcode-client/1.0'},
                  params={'lang': 'en'},
                  timeout=10.0) as client:

    r1 = client.get('/get', params={'page': 1})
    r2 = client.get('/user-agent')

    print(r1.url)
    print(r1.json()['args'])
    print(r2.json())

The client has a base URL, a default header, a default query parameter, and a timeout. Relative URLs are joined with the base URL, and the default query parameters are merged with the parameters of each request. Using the client as a context manager closes its connections at the end.

$ python client.py
https://httpbin.org/get?lang=en&page=1
{'lang': 'en', 'page': '1'}
{'user-agent': 'zetcode-client/1.0'}

Python httpx redirects

By default, httpx does not follow redirects. The follow_redirects option enables it.

redirects.py
import httpx

r = httpx.get('https://httpbin.org/redirect/2')
print(r.status_code, r.headers['location'])

r = httpx.get('https://httpbin.org/redirect/2', follow_redirects=True)
print(r.status_code, r.url)

for resp in r.history:
    print(resp.status_code, resp.url)

Without following, we get the 302 response with the Location header. With follow_redirects=True, httpx follows the redirects to the final page; the intermediate responses are stored in the history list.

$ python redirects.py
302 /relative-redirect/1
200 https://httpbin.org/get
302 https://httpbin.org/redirect/2
302 https://httpbin.org/relative-redirect/1

Python httpx errors

httpx does not raise an exception for error status codes; we call raise_for_status. Network problems raise exceptions derived from httpx.TransportError.

errors.py
import httpx

try:
    r = httpx.get('https://httpbin.org/status/404')
    print(r.status_code, r.is_error)
    r.raise_for_status()
except httpx.HTTPStatusError as e:
    print(e)

try:
    httpx.get('https://httpbin.org/delay/5', timeout=1.0)
except httpx.TimeoutException as e:
    print(type(e).__name__, e)

try:
    httpx.get('https://nonexistent.example/')
except httpx.ConnectError as e:
    print(type(e).__name__, e)

The raise_for_status method raises HTTPStatusError for 4xx and 5xx responses. The default timeout is five seconds; here we set it to one second, so a request to an endpoint that waits five seconds raises ReadTimeout. A host that does not exist raises ConnectError.

$ python errors.py
404 True
Client error '404 Not Found' for url 'https://httpbin.org/status/404'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
ReadTimeout The read operation timed out
ConnectError [Errno -2] Name or service not known

Python httpx stream data

For larger downloads, we can stream responses that do not load the entire response body into memory at once. For streaming, we use the httpx.stream function.

sync_stream.py
import httpx

url = 'https://httpbin.org/bytes/102400'

with open('data.bin', 'wb') as f:
    with httpx.stream('GET', url) as r:

        total = int(r.headers['content-length'])
        size = 0

        for chunk in r.iter_bytes(chunk_size=32768):
            f.write(chunk)
            size += len(chunk)
            print(f'{size:>6} / {total} bytes')

The httpbin.org/bytes/n endpoint returns n random bytes. We iterate over the binary content in chunks of 32 KiB with the iter_bytes method, write them to a file, and print the progress. The total size is taken from the Content-Length header.

$ python sync_stream.py
 32768 / 102400 bytes
 65536 / 102400 bytes
 98304 / 102400 bytes
102400 / 102400 bytes

Python httpx HTTP/2

HTTP/2 is enabled with the http2 option of the client. It requires the h2 package, installed with the http2 extra.

http2.py
import httpx

with httpx.Client(http2=True) as client:
    r = client.get('https://example.com')
    print(r.http_version)

with httpx.Client() as client:
    r = client.get('https://example.com')
    print(r.http_version)

The protocol is negotiated during the TLS handshake. If the server supports HTTP/2, the request uses it; otherwise, httpx falls back to HTTP/1.1. Without the option, httpx uses HTTP/1.1.

$ python http2.py
HTTP/2
HTTP/1.1

Testing with MockTransport

The transport is the part of the client that sends requests. The MockTransport class calls a function instead of using the network, which is useful in tests.

mock_transport.py
import httpx


def handler(request: httpx.Request) -> httpx.Response:

    if request.url.path == '/users/1':
        return httpx.Response(200, json={'id': 1, 'name': 'John Doe'})

    return httpx.Response(404, json={'error': 'not found'})


def get_user_name(client: httpx.Client, user_id: int) -> str | None:

    r = client.get(f'/users/{user_id}')

    if r.status_code == 404:
        return None

    return r.json()['name']


transport = httpx.MockTransport(handler)

with httpx.Client(transport=transport, base_url='https://api.example.com') as client:
    print(get_user_name(client, 1))
    print(get_user_name(client, 2))

The handler function receives the request and returns a response. The get_user_name function works with any client, so in the test we pass it a client with the mock transport. No network connection is made.

$ python mock_transport.py
John Doe
None

Python httpx async GET request

The following example generates a simple asynchronous GET request.

async_get.py
import asyncio

import httpx


async def main():

    async with httpx.AsyncClient() as client:
        r = await client.get('https://httpbin.org/get', params={'q': 'async'})

    print(r.status_code)
    print(r.json()['args'])


asyncio.run(main())

With async def, we create a coroutine. We create an asynchronous HTTP client with httpx.AsyncClient; it has the same methods as the synchronous client, but they are coroutines. With the await keyword, the get coroutine is started and the control is given back to the event loop until the response arrives; in the meantime, the event loop can run other tasks. The asyncio.run function starts the event loop and runs the main coroutine.

$ python async_get.py
200
{'q': 'async'}

Python httpx multiple asynchronous GET requests

The asyncio.gather function runs coroutines concurrently.

async_mul_get.py
import asyncio

import httpx

urls = ['https://example.com', 'https://httpbin.org/get',
        'https://httpbin.org/html', 'https://httpbin.org/status/404']


async def main():

    async with httpx.AsyncClient() as client:
        resps = await asyncio.gather(*(client.get(url) for url in urls))

    for resp in resps:
        print(resp.status_code, resp.headers['content-type'], resp.url)


asyncio.run(main())

We create one asynchronous client and send four GET requests through it. The generator expression creates a coroutine for each URL; they are unpacked into the arguments of gather with the * operator. When all coroutines are completed, gather returns the responses in the order of the URLs.

$ python async_mul_get.py
200 text/html; charset=utf-8 https://example.com
200 application/json https://httpbin.org/get
200 text/html; charset=utf-8 https://httpbin.org/html
404 text/html; charset=utf-8 https://httpbin.org/status/404

Python httpx async POST JSON request

The following example shows how to send an asynchronous POST request with JSON data.

async_post_json.py
import asyncio

import httpx


async def main():

    data = {'int': 123, 'boolean': True, 'list': ['a', 'b', 'c']}

    async with httpx.AsyncClient() as client:
        r = await client.post('https://httpbin.org/post', json=data)

    print(r.json()['json'])


asyncio.run(main())

The data is set to the json option of the post coroutine. Form data would be passed in the data option, as in the synchronous API.

$ python async_post_json.py
{'boolean': True, 'int': 123, 'list': ['a', 'b', 'c']}

Python httpx asynchronous stream request

The example shows how to download a binary file in an asynchronous stream.

async_stream.py
import asyncio

import httpx

url = 'https://httpbin.org/bytes/102400'


async def main():

    size = 0

    async with httpx.AsyncClient() as client:
        async with client.stream('GET', url) as r:
            with open('data.bin', 'wb') as f:
                async for chunk in r.aiter_bytes():
                    f.write(chunk)
                    size += len(chunk)

    print(f'downloaded {size} bytes')


asyncio.run(main())

We use the client.stream asynchronous context manager and iterate over the chunks with async for and aiter_bytes.

$ python async_stream.py
downloaded 102400 bytes

Comparing synchronous and asynchronous requests

In the following two examples, we compare the efficiency of a group of synchronous and asynchronous requests. The httpbin.org/delay/n endpoint waits n seconds before it responds.

multiple_sync.py
import time

import httpx

urls = [f'https://httpbin.org/delay/{n}' for n in (1, 1, 2, 1, 1)]

start = time.perf_counter()

with httpx.Client(timeout=30) as client:
    for url in urls:
        r = client.get(url)
        print(r.status_code, url)

print(f'Elapsed: {time.perf_counter() - start:.2f} s')

We generate five synchronous GET requests one after another and calculate the elapsed time with time.perf_counter.

multiple_async.py
import asyncio
import time

import httpx

urls = [f'https://httpbin.org/delay/{n}' for n in (1, 1, 2, 1, 1)]


async def main():

    async with httpx.AsyncClient(timeout=30) as client:
        resps = await asyncio.gather(*(client.get(url) for url in urls))

    for url, r in zip(urls, resps):
        print(r.status_code, url)


start = time.perf_counter()
asyncio.run(main())
print(f'Elapsed: {time.perf_counter() - start:.2f} s')

We generate the same five GET requests asynchronously. The synchronous version takes at least the sum of the delays, six seconds, plus the network overhead. The asynchronous version waits for all responses at the same time, so it takes about as long as the slowest request.

$ python multiple_sync.py
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/1
Elapsed: 7.99 s
$ python multiple_async.py
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/1
Elapsed: 2.78 s

Source

Python httpx documentation

In this article we have generated synchronous and asynchronous web requests in Python with the httpx module.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.