Python5 min read9 sections

Python asyncio for data ingestion: gather, semaphores and timeouts

Most of the time spent pulling data from an API is waiting. Learn to wait for many calls at once, keep the results in order, stay inside the API's limits and survive a failed call.

Updated Written for: Freshers and beginners who know basic Python functions and want to ingest data from APIs faster.

What you will learn

  • Calls to an API spend most of their time waiting, so running them at the same time can cut the total to about the slowest single call.
  • asyncio.gather runs coroutines together and returns results in the order you asked, not the order they finished.
  • A semaphore caps how many calls run at once, which keeps you inside an API's rate limits.
  • return_exceptions=True lets every call finish, so one failed page does not lose the others.
  • asyncio helps with waiting, not with heavy calculation. CPU-bound work needs processes or Spark.
On this page
  1. What you will learn
  2. 1. The problem: 5 pages, one at a time
  3. 2. Coroutines: async and await
  4. 3. asyncio.gather: run them together
  5. 4. Do not hit the API with everything at once
  6. 5. When one call fails
  7. 6. Threads as another option
  8. 7. When asyncio does not help
  9. 8. Check yourself

Pulling data from an API is slow in a particular way: most of the time your program is not working, it is waiting for a reply. Python's asyncio lets one program wait for many replies at once. This guide uses a pretend API so every number is reproducible, and shows the four things you need for real ingestion: running calls together, keeping the order of results, limiting how many run at once, and handling a call that fails.

What you will learn

  • What a coroutine is and how async and await work.
  • How asyncio.gather runs many calls at the same time and keeps results in order.
  • How to limit the number of calls running at once with a semaphore.
  • How to handle one failing call without losing the others, and how to set a timeout.
  • When asyncio does not help, so you do not use it for the wrong job.

The problem: 5 pages, one at a time

An API returns 5 pages of orders. Each call takes 0.2 seconds, because most of that time is the network. We pretend with sleep, which waits without doing any work.

PythonPySpark
import asyncio
import time

PAGES = [1, 2, 3, 4, 5]

def fetch_page_blocking(page):
    time.sleep(0.2)
    return f"page {page}"

start = time.perf_counter()
results = [fetch_page_blocking(page) for page in PAGES]
elapsed = time.perf_counter() - start

print(results)
print("took at least 1.0 seconds:", elapsed >= 1.0)
['page 1', 'page 2', 'page 3', 'page 4', 'page 5']
took at least 1.0 seconds: True

5 calls of 0.2 seconds, one after another, take a full second. The program spent almost all of that second doing nothing. With 500 pages it would be 100 seconds.

Coroutines: async and await

A function written with async def is a coroutine. Calling it does not run it; it gives you a coroutine object. await is where it can pause and let another coroutine run. asyncio.run starts the loop that switches between them.

PythonPySpark
async def fetch_page(page):
    await asyncio.sleep(0.2)
    return f"page {page}"

coro = fetch_page(1)
print(type(coro).__name__)
print(asyncio.run(coro))
coroutine
page 1

The first line shows that calling fetch_page did not run anything yet; it only created the coroutine. The second line is the result after asyncio.run executed it.

asyncio.gather: run them together

Now start all 5 calls and wait for all of them. While one call waits, the loop starts the next, so the waits overlap.

PythonPySpark
async def fetch_all():
    return await asyncio.gather(*(fetch_page(page) for page in PAGES))

start = time.perf_counter()
results = asyncio.run(fetch_all())
elapsed = time.perf_counter() - start

print(results)
print("took under 0.5 seconds:", elapsed < 0.5)
['page 1', 'page 2', 'page 3', 'page 4', 'page 5']
took under 0.5 seconds: True

One after another: 1.0 s

  • page 1
  • page 2
  • page 3
  • page 4
  • page 5

asyncio.gather: about 0.2 s

  • page 1
  • page 2
  • page 3
  • page 4
  • page 5

All five waits overlap. The total is the slowest call.

Figure 1. 5 calls of 0.2 seconds each. One after another they take 1.0 seconds in total. Run together with asyncio.gather they overlap and take about 0.2 seconds.

The total fell from about 1 second to about 0.2 seconds, which is the time of the slowest single call.

The results keep the order you asked for

gather returns results in the order you passed the calls in, not the order they finished. Here 3 calls take different times.

PythonPySpark
finished = []

async def fetch_slow(name, delay):
    await asyncio.sleep(delay)
    finished.append(name)
    return name

async def three_calls():
    return await asyncio.gather(
        fetch_slow("a", 0.3), fetch_slow("b", 0.1), fetch_slow("c", 0.2)
    )

results = asyncio.run(three_calls())
print("results in request order:", results)
print("finished in this order:  ", finished)
results in request order: ['a', 'b', 'c']
finished in this order:   ['b', 'c', 'a']

Call a was started first and finished last. The results list still starts with a, so you can match each result to its request without extra work.

Do not hit the API with everything at once

Starting 500 calls together can get you blocked, or run you out of connections. A semaphore is a counter that lets only a fixed number of coroutines into a section at once. Here at most 2 calls may run together, and we record the highest number that really did.

PythonPySpark
running = 0
max_running = 0
limit = asyncio.Semaphore(2)

async def fetch_limited(page):
    global running, max_running
    async with limit:
        running += 1
        max_running = max(max_running, running)
        await asyncio.sleep(0.1)
        running -= 1
        return f"page {page}"

async def fetch_all_limited():
    return await asyncio.gather(*(fetch_limited(page) for page in PAGES))

results = asyncio.run(fetch_all_limited())
print(results)
print("most calls running at once:", max_running)
['page 1', 'page 2', 'page 3', 'page 4', 'page 5']
most calls running at once: 2

All 5 pages came back, and never more than 2 were in flight. In real ingestion, choose the limit from what the API allows, and create the semaphore inside the loop that uses it, as these examples do.

When one call fails

By default, if one call raises an error, gather raises it and you lose sight of the others. With return_exceptions=True every call finishes, and a failure shows up in the list as an exception object.

PythonPySpark
async def fetch_maybe_fail(page):
    await asyncio.sleep(0.05)
    if page == 3:
        raise ValueError("server error on page 3")
    return f"page {page}"

async def fetch_safely():
    return await asyncio.gather(*(fetch_maybe_fail(page) for page in PAGES), return_exceptions=True)

results = asyncio.run(fetch_safely())
for page, result in zip(PAGES, results):
    kind = "FAILED" if isinstance(result, Exception) else "ok"
    print(page, kind, result)
1 ok page 1
2 ok page 2
3 FAILED server error on page 3
4 ok page 4
5 ok page 5

Page 3 failed and the other four succeeded. Now your code can retry just page 3. A call that hangs forever is a different problem, and asyncio.wait_for solves it with a time limit.

PythonPySpark
async def hangs():
    await asyncio.sleep(10)

async def with_timeout():
    try:
        await asyncio.wait_for(hangs(), timeout=0.1)
    except asyncio.TimeoutError:
        return "gave up after 0.1 seconds"

print(asyncio.run(with_timeout()))
gave up after 0.1 seconds

Threads as another option

Not every library supports asyncio. A popular blocking library, such as the requests package, cannot be awaited. For those, a thread pool gives the same overlap, because a thread that is waiting for the network lets the others run.

PythonPySpark
from concurrent.futures import ThreadPoolExecutor

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    results = list(pool.map(fetch_page_blocking, PAGES))
elapsed = time.perf_counter() - start

print(results)
print("took under 0.5 seconds:", elapsed < 0.5)
['page 1', 'page 2', 'page 3', 'page 4', 'page 5']
took under 0.5 seconds: True

The result and the speed-up are the same. Use asyncio when your HTTP library supports it, and threads when it does not.

When asyncio does not help

Asyncio speeds up waiting. It does not speed up computing. If each page needs a long calculation, such as hashing every row or parsing a large file, the coroutines still run one at a time on one core, because only one can use the processor at any moment. For that kind of work, use multiple processes, or let Spark spread it over many machines.

A rule of thumb: if the job spends its time waiting for the network or the disk, asyncio or threads help. If it spends its time calculating, they do not.

Output
# Real HTTP with asyncio needs a library that supports it, for example:
#   async with httpx.AsyncClient() as client:
#       responses = await asyncio.gather(*(client.get(url) for url in urls))
# The pattern is the same as the examples above.

Check yourself

  • 5 calls take 0.2 seconds each. About how long do they take one after another, and about how long with gather?
  • Call a starts first but finishes last. Where does its result appear in the list gather returns?
  • What does return_exceptions=True change about how a failure is reported?
  • Why does asyncio not make a CPU-heavy hashing job faster?

Practise it: try Retry a flaky operation with a cap in the Python editor, and read the Concurrency, asyncio, and the GIL lesson in the Core Python track.

Keep practising

Take what you just read into the editor.

Browse all 36 guides