How do async and await work in Python, and when do you reach for asyncio?
async def defines a coroutine function. Calling it does not run the body — it creates a coroutine object that has to be driven by an event loop. await inside that body is a suspension point: the coroutine hands control back to the loop, which can run other ready tasks in the meantime, and the coroutine resumes later when its awaited operation is finished.
The event loop is what makes it work. It keeps a set of tasks that are ready to run and a set of callbacks waiting on I/O. When a task awaits a socket read, the loop registers interest in that socket and moves on to the next ready task; when the socket becomes readable, the task is scheduled again. Thousands of tasks can be in flight in one thread, because a task is cheap — just a coroutine plus its state — unlike a thread with its own stack.
So when do you reach for it? When you have a lot of concurrent I/O and you want one thread rather than hundreds. An async web server handling thousands of connections, a script fetching many URLs at once, or a client talking to several services in parallel are the classic cases. The rule of thumb is that asyncio is for concurrency, not parallelism — it overlaps waiting, it does not compute faster.
Two practical points for an interview. Never call a blocking function inside a coroutine, because it blocks the whole event loop — use an async version, or push the blocking work into a thread with asyncio.to_thread. And asyncio.gather lets you await several coroutines at once, which is usually the reason you reach for asyncio in the first place.