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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
In this article we have worked with the Python aiohttp module.
Author
List all Python tutorials.