ZetCode

Python aiohttp

last modified October 30, 2026 Python 3.15

In this article we show how to use the aiohttp module to create asynchronous HTTP clients and servers in Python.

With asynchronous programming, we can execute many tasks concurrently in a single thread. See Python async/await and Python asyncio for an introduction.

With the aiohttp module, we can create asynchronous HTTP clients and servers in Python. The module also supports WebSockets. It allows to create web servers with pluggable middleware and routing. It is installed with the following command:

$ pip install aiohttp

Python aiohttp simple client

The first example is a simple asynchronous HTTP client.

simple_client.py
import asyncio
import re

import aiohttp

url = 'https://example.com'


async def main():

    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:

            print('Status:', response.status)
            print('Content type:', response.content_type)

            data = await response.text()
            print('Length:', len(data))

            title = re.search(r'<title>(.*?)</title>', data).group(1)
            print('Title:', title)


asyncio.run(main())

In the program, we connect to a small web site and retrieve its status and content.

async with aiohttp.ClientSession() as session:

A client session is created. It holds a connection pool and should be shared by all requests of the program, rather than created for each request.

async with session.get(url) as response:

We create a GET request with the get method. The response is used as an asynchronous context manager, which releases the connection at the end of the block.

print('Status:', response.status)
print('Content type:', response.content_type)

From the response object, we get the status code via the status attribute and the media type via content_type. These are available as soon as the headers arrive.

data = await response.text()

Reading the body is an IO operation, so the text method must be awaited. We print the length of the page and its title.

asyncio.run(main())

The asyncio.run function creates an event loop, runs the main coroutine, and closes the loop.

$ python simple_client.py
Status: 200
Content type: text/html
Length: 577
Title: Example Domain

Python aiohttp multiple async requests

In the next example, we generate multiple asynchronous requests with aiohttp.

multiple.py
import asyncio
import time

import aiohttp

urls = ['https://example.com', 'https://httpbin.org/get',
        'https://www.python.org', 'https://github.com', 'https://pypi.org']


async def get_status(session, url):
    async with session.get(url) as resp:
        return resp.status


async def main():
    async with aiohttp.ClientSession() as session:
        statuses = await asyncio.gather(*(get_status(session, url)
                                          for url in urls))

    for url, status in zip(urls, statuses):
        print(f'{url:25} {status}')


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

In the program, we create asynchronous HTTP GET requests to multiple URLs.

async def get_status(session, url):
    async with session.get(url) as resp:
        return resp.status

The coroutine sends a request with the shared session and returns the status code. The response is released when the block ends.

statuses = await asyncio.gather(*(get_status(session, url)
                                  for url in urls))

We create a coroutine for each URL and unpack them into the arguments of asyncio.gather with the * operator. The requests run concurrently. If all coroutines complete successfully, the result is a list of the returned values in the order of the URLs.

$ python multiple.py
https://example.com       200
https://httpbin.org/get   200
https://www.python.org    200
https://github.com        200
https://pypi.org          200
elapsed: 0.58 s

Query parameters and JSON responses

The params argument adds query parameters to the URL, and the json method decodes a JSON response body. We use the httpbin.org testing service, which echoes the request back.

query_params.py
import asyncio

import aiohttp


async def main():
    params = {'name': 'John Doe', 'occupation': 'gardener'}

    async with aiohttp.ClientSession() as session:
        async with session.get('https://httpbin.org/get',
                               params=params) as resp:
            data = await resp.json()

            print(resp.url)
            print(data['args'])


asyncio.run(main())

The parameters are encoded in the URL; the space in John Doe becomes +. The service returns the parsed parameters in the args field.

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

Python aiohttp POST request

The post method sends a POST request. With the json argument, the data is serialized to JSON and the Content-Type header is set.

post_json.py
import asyncio

import aiohttp


async def main():
    user = {'name': 'John Doe', 'email': 'john.doe@example.com'}

    async with aiohttp.ClientSession() as session:
        async with session.post('https://httpbin.org/post',
                                json=user) as resp:
            data = await resp.json()

            print(resp.status)
            print(data['headers']['Content-Type'])
            print(data['json'])


asyncio.run(main())

We send a user object to httpbin.org/post. The service returns the request headers and the decoded JSON data.

$ python post_json.py
200
application/json
{'email': 'john.doe@example.com', 'name': 'John Doe'}

Errors and timeouts

By default, aiohttp does not raise an exception for error status codes. The raise_for_status method raises a ClientResponseError for 4xx and 5xx responses. The ClientTimeout object sets the time limits of requests.

errors.py
import asyncio

import aiohttp


async def fetch(session, url):
    try:
        async with session.get(url) as resp:
            resp.raise_for_status()
            return f'{url}: {resp.status}'
    except aiohttp.ClientResponseError as e:
        return f'{url}: HTTP error {e.status} {e.message}'
    except TimeoutError:
        return f'{url}: timed out'


async def main():
    timeout = aiohttp.ClientTimeout(total=2)
    urls = ['https://httpbin.org/status/200', 'https://httpbin.org/status/404',
            'https://httpbin.org/delay/5']

    async with aiohttp.ClientSession(timeout=timeout) as session:
        for res in await asyncio.gather(*(fetch(session, url) for url in urls)):
            print(res)


asyncio.run(main())

We request a successful page, a missing page, and a page that responds after five seconds.

timeout = aiohttp.ClientTimeout(total=2)

The total limit applies to the whole operation, including connecting and reading the body. A request that takes longer raises a TimeoutError. The default total timeout is five minutes.

$ python errors.py
https://httpbin.org/status/200: 200
https://httpbin.org/status/404: HTTP error 404 Not Found
https://httpbin.org/delay/5: timed out

Python aiohttp simple web server

The following example creates a simple web server.

simple_web.py
from aiohttp import web


async def home(request):
    return web.Response(text='home page')


app = web.Application()
app.add_routes([web.get('/', home)])

web.run_app(app, host='localhost', port=8080)

The web server has one route that returns a text message.

async def home(request):
    return web.Response(text='home page')

The home handler is a coroutine that receives the request and returns a text response.

app = web.Application()
app.add_routes([web.get('/', home)])

A web application is created. A new route is added with add_routes; the / path is mapped to the home handler.

web.run_app(app, host='localhost', port=8080)

The run_app function starts the web application and runs it until it is interrupted with Ctrl+C. Without the host argument, the server listens on all network interfaces.

We start the server in the background and send requests with curl. A path without a route returns a 404 response.

$ python simple_web.py &
======== Running on http://localhost:8080 ========
(Press CTRL+C to quit)
$ curl localhost:8080
home page
$ curl -i localhost:8080/about
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Date: Fri, 30 Oct 2026 17:48:37 GMT
Server: Python/3.15 aiohttp/3.14.5

404: Not Found

Python aiohttp route decorators

We can use decorators to define routes. The routes are collected in a RouteTableDef.

decor.py
from aiohttp import web

routes = web.RouteTableDef()


@routes.get('/')
async def home(request):
    return web.Response(text='home page')


@routes.get('/about')
async def about(request):
    return web.Response(text='<h1>About</h1>', content_type='text/html')


app = web.Application()
app.add_routes(routes)

web.run_app(app, host='localhost', port=8080)

This program creates a web server with two routes. The routes are defined with the @routes.get decorator.

return web.Response(text='<h1>About</h1>', content_type='text/html')

The default content type is text/plain; the content_type argument changes it.

$ python decor.py &
======== Running on http://localhost:8080 ========
(Press CTRL+C to quit)
$ curl localhost:8080
home page
$ curl -i localhost:8080/about
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 14
Date: Fri, 30 Oct 2026 17:48:38 GMT
Server: Python/3.15 aiohttp/3.14.5

<h1>About</h1>

Python aiohttp JSON response

JSON (JavaScript Object Notation) is a lightweight data-interchange format. The official Internet media type for JSON is application/json.

The web.json_response function generates a JSON response. In the next example, we also use path and query parameters.

send_json.py
from aiohttp import web

routes = web.RouteTableDef()

users = [
    {'id': 1, 'name': 'John Doe', 'email': 'john.doe@example.com'},
    {'id': 2, 'name': 'Roger Roe', 'email': 'roger.roe@example.com'},
]


@routes.get('/users')
async def get_users(request):
    return web.json_response(users)


@routes.get('/users/{id}')
async def get_user(request):
    uid = int(request.match_info['id'])

    for user in users:
        if user['id'] == uid:
            return web.json_response(user)

    raise web.HTTPNotFound(text=f'user {uid} not found')


@routes.get('/search')
async def search(request):
    q = request.query.get('q', '').lower()
    found = [user for user in users if q in user['name'].lower()]
    return web.json_response(found)


app = web.Application()
app.add_routes(routes)

web.run_app(app, host='localhost', port=8080)

The /users path returns all users.

@routes.get('/users/{id}')
async def get_user(request):
    uid = int(request.match_info['id'])

The {id} part of the path is a variable. Its value is available in the match_info dictionary as a string.

raise web.HTTPNotFound(text=f'user {uid} not found')

HTTP errors are raised as exceptions. The HTTPNotFound exception produces a 404 response.

q = request.query.get('q', '').lower()

Query parameters are in the query multidict.

$ python send_json.py &
======== Running on http://localhost:8080 ========
(Press CTRL+C to quit)
$ curl localhost:8080/users
[{"id": 1, "name": "John Doe", "email": "john.doe@example.com"}, {"id": 2, "name": "Roger Roe", "email": "roger.roe@example.com"}]
$ curl localhost:8080/users/2
{"id": 2, "name": "Roger Roe", "email": "roger.roe@example.com"}
$ curl -i localhost:8080/users/3
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 16
Date: Fri, 30 Oct 2026 17:48:39 GMT
Server: Python/3.15 aiohttp/3.14.5

user 3 not found
$ curl 'localhost:8080/search?q=roe'
[{"id": 2, "name": "Roger Roe", "email": "roger.roe@example.com"}]

Python aiohttp POST handler

The request.json coroutine reads and decodes a JSON request body.

post_handler.py
from aiohttp import web

routes = web.RouteTableDef()

users = []


@routes.post('/users')
async def create_user(request):
    try:
        data = await request.json()
    except ValueError:
        raise web.HTTPBadRequest(text='invalid JSON')

    if 'name' not in data:
        raise web.HTTPBadRequest(text='name is required')

    user = {'id': len(users) + 1, 'name': data['name']}
    users.append(user)

    return web.json_response(user, status=201)


@routes.get('/users')
async def get_users(request):
    return web.json_response(users)


app = web.Application()
app.add_routes(routes)

web.run_app(app, host='localhost', port=8080)

The create_user handler adds a new user and returns it with the 201 Created status.

try:
    data = await request.json()
except ValueError:
    raise web.HTTPBadRequest(text='invalid JSON')

An invalid body raises a ValueError, which we turn into a 400 response. The json method does not check the Content-Type header; the second request is sent by curl as form data and is still accepted.

$ python post_handler.py &
======== Running on http://localhost:8080 ========
(Press CTRL+C to quit)
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"name": "John Doe"}' localhost:8080/users
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 29
Date: Fri, 30 Oct 2026 17:48:39 GMT
Server: Python/3.15 aiohttp/3.14.5

{"id": 1, "name": "John Doe"}
$ curl -X POST -d '{"name": "Roger Roe"}' localhost:8080/users
{"id": 2, "name": "Roger Roe"}
$ curl -X POST -d '{"email": "x@example.com"}' localhost:8080/users
name is required
$ curl localhost:8080/users
[{"id": 1, "name": "John Doe"}, {"id": 2, "name": "Roger Roe"}]

Python aiohttp middleware

A middleware is a coroutine that wraps every request handler. It can modify the request and the response, or handle errors.

middleware.py
import asyncio
import time

from aiohttp import web


def elapsed_ms(start):
    return (time.perf_counter() - start) * 1000


@web.middleware
async def timing(request, handler):
    start = time.perf_counter()

    try:
        response = await handler(request)
    except web.HTTPException as e:
        print(f'{request.method} {request.path} {e.status} {elapsed_ms(start):.1f} ms')
        raise

    response.headers['X-Process-Time'] = f'{elapsed_ms(start):.1f} ms'
    print(f'{request.method} {request.path} {response.status} {elapsed_ms(start):.1f} ms')

    return response


async def home(request):
    return web.Response(text='home page')


async def slow(request):
    await asyncio.sleep(0.2)
    return web.Response(text='slow page')


app = web.Application(middlewares=[timing])
app.add_routes([web.get('/', home), web.get('/slow', slow)])

web.run_app(app, host='localhost', port=8080, print=None)

The timing middleware measures how long the handler takes, adds the time in a response header, and prints a log line. The slow handler sleeps for 0.2 seconds to simulate work.

try:
    response = await handler(request)
except web.HTTPException as e:
    print(f'{request.method} {request.path} {e.status} {elapsed_ms(start):.1f} ms')
    raise

The middleware calls the next handler in the chain. HTTP errors, including the 404 for a path without a route, are raised as HTTPExceptions and do not return a response. We log them and re-raise the exception, so that aiohttp sends the error response.

web.run_app(app, host='localhost', port=8080, print=None)

The print=None argument suppresses the startup message.

$ python middleware.py &
$ curl -i localhost:8080/slow
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Process-Time: 200.3 ms
Content-Length: 9
Date: Fri, 30 Oct 2026 17:48:40 GMT
Server: Python/3.15 aiohttp/3.14.5

slow page
GET /slow 200 200.4 ms
$ curl localhost:8080/
home page
GET / 200 0.1 ms
$ curl localhost:8080/missing
404: Not Found
GET /missing 404 0.0 ms

Server and client in one program

The run_app function blocks until the server stops. To run a server together with other coroutines, we use the AppRunner and TCPSite classes.

server_client.py
import asyncio

import aiohttp
from aiohttp import web


async def hello(request):
    name = request.query.get('name', 'guest')
    return web.json_response({'message': f'Hello {name}!'})


async def main():
    app = web.Application()
    app.add_routes([web.get('/hello', hello)])

    runner = web.AppRunner(app)
    await runner.setup()

    site = web.TCPSite(runner, 'localhost', 8080)
    await site.start()

    async with aiohttp.ClientSession('http://localhost:8080') as session:
        for name in [None, 'Jane']:
            params = {'name': name} if name else {}

            async with session.get('/hello', params=params) as resp:
                print(resp.status, await resp.json())

    await runner.cleanup()


asyncio.run(main())

We start a server, send it two requests with a client session, and shut the server down.

runner = web.AppRunner(app)
await runner.setup()

site = web.TCPSite(runner, 'localhost', 8080)
await site.start()

The start method starts listening and returns immediately; the server runs in the event loop while main continues.

async with aiohttp.ClientSession('http://localhost:8080') as session:

A session created with a base URL accepts relative paths.

await runner.cleanup()

The cleanup method stops the server and closes the connections. This approach is also useful in tests.

$ python server_client.py
200 {'message': 'Hello guest!'}
200 {'message': 'Hello Jane!'}

Source

Python aiohttp documentation

In this article we have worked with the Python aiohttp 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.