---
title: "await Is Not a Context Switch: Understanding Python's Coroutines vs Tasks"
description: "Python’s async model is misunderstood, especially by engineers coming from JS or C#. In Python, awaiting a coroutine doesn’t yield to the event loop. Only tasks create concurrency. This post explains why that distinction matters and how it affects locking, design, and correctness."
author: mehdi-abaakouk
date: 2025-11-25
category: deep-dives
readTimeMinutes: 7
featured: false
coverImage: ../../assets/blog/await-is-not-a-context-switch-understanding-pythons-coroutines-vs-tasks.png
---

Every engineer has had that moment during a review where a comment sticks in their head longer than it should.

In my case, it was a simple suggestion:

> “You should add more locks here: this code is async, so anything might interleave.”

The code in question touched a shared cache, and on the surface the comment made sense. Multiple asyncio tasks were hitting the same structure, and the function modifying it was async. Shouldn't that mean I need more locks?

That review pushed me down a rabbit hole. Not about the cache (it was tiny) but about the mental model many engineers (including experienced ones) bring to Python's async system. A model shaped by JavaScript or C#: all languages where await means *"yield to the runtime now."*

But Python isn't those languages. And misunderstanding this **fundamental difference** leads to unnecessary locking, accidental complexity, and subtle bugs.

This post is the explanation I wish more engineers had.

## The misconception: await gives up control (in every language… right?)

If you're coming from JavaScript, the rule is simple:

- Every await always yields to the event loop.
- Every async function always returns a task (a Promise).
- The moment you write await, the runtime can schedule something else.

In C#, the story is nearly identical:

- `async` functions return `Task<T>` or `Task`.
- `await` always represents a suspension point.
- The runtime decides when to resume you.

In Java's virtual-thread world (Project Loom), the principle is very similar: when you submit work to run asynchronously, typically via an `ExecutorService` backed by virtual threads, you're creating tasks. And when you call `Future.get()`, the virtual thread suspends until the result is ready. The suspension is inexpensive, but it still constitutes a full scheduling boundary.

So developers internalize one big rule:

> “Any async boundary is a suspension point.“

And then they bring that rule to Python.

## But Python is different: it has two async concepts

Python splits things into:

### 1. Coroutines

Defined with async def, but not scheduled. A coroutine object is *just* a state machine with potential suspension points.

```python
async def foo():
    return 42

coro = foo()   # <- coroutine object, not running
```

When you run:

```text
result = await foo()
```

Python immediately steps into the coroutine and executes it **inside the current task**, synchronously, until it either finishes or hits a suspension point (await something_not_ready).

No event-loop scheduling happens here.

### 2. Tasks

Created with asyncio.create_task(coro). Tasks are the unit of concurrency in Python. The event loop interleaves tasks, not coroutines.

This distinction is not cosmetic: it’s the reason many developers misunderstand Python’s async semantics.

## The key truth: await on a coroutine does NOT yield to the event loop

This sentence is the entire post:

> Awaiting a coroutine does not give control back to the event loop. Awaiting a task does.

A coroutine is more like a nested function call that can pause, but it doesn't pause *by default*. It only yields if and when it reaches an awaitable that isn't ready.

In contrast:

- JavaScript
- Java
- C#

Do not expose this difference. In those languages, an "async function" is *always* a task. You never await a "bare coroutine." Every await is a potential context switch.

Python breaks that assumption.

## Concrete Example 1: Awaiting a coroutine is synchronous

Let's make the behavior painfully explicit.

```python
import asyncio

async def child():
    print("child start")
    await asyncio.sleep(0)
    print("child end")

async def parent():
    print("parent before")
    await child()        # <-- awaiting a coroutine (not a task)
    print("parent after")

asyncio.run(parent())
```

Output:

```text
parent before
child start
child end
parent after
```

Notice what **didn't** happen:

- No other task ran between "child start" and "child end".
- `await child()` did not give the event loop a chance to schedule anything else until `child()` itself awaited `asyncio.sleep`.

`await child()` simply *inlined* the coroutine's body.

This is not how JavaScript behaves. This is not how C# behaves. This is not how Java behaves.

## Concrete Example 2: Tasks actually introduce concurrency

Change one line:

```python
async def parent():
    print("parent before")
    task = asyncio.create_task(child())   # <-- spawn a task
    print("parent after creating task")
    await task
```

Now the output interleaves depending on the scheduler:

```text
parent before
parent after creating task
child start
child end
```

Because now we have a **task**, and awaiting a task *does* yield to the event loop.

Tasks are where concurrency comes from, not coroutines.

This single difference is where most incorrect locking recommendations arise.

## Suspension points define concurrency, not async or await

Now let's extract the general rule:

- An async def function is *not* automatically concurrent.
- `await` is *not* a scheduling point unless the inner awaitable suspends.
- Concurrency exists **only across tasks** and **only at actual suspension points**.

This is why the code review suggestion I received, "add more locks, it’s async!", was based on the wrong mental model.

My mutation block contained **no awaits**. The only awaits happened before acquiring the lock. Therefore:

- The critical section was atomic relative to the event loop.
- No other task could interleave inside the mutation.
- More locks would not increase safety.

The cache wasn't the story. My reviewer's misconception was.

## Why Python chose this design

Python's async model evolved from generators (`yield`, `yield from`), rather than green threads or promises. Coroutines are an evolution of these primitives.

This legacy leads to:

- A more explicit boundary between *structured control flow* and *scheduled concurrency*.
- The ability to write async code that behaves synchronously until a real suspension occurs.
- Fine-grained control over when interleaving can happen.

It also leads to confusion among developers coming from JavaScript, Java, or C#, languages where async automatically means "this is a task."

Python leaves "is this a task?" up to you.

## Putting it all together: a mental model that actually works

Here is the model I now advocate whenever reviewing asyncio code:

1. **Coroutines are callables with potential suspension points: **they do not run concurrently.
2. **Only tasks introduce concurrency:  **if you never call `asyncio.create_task`, you may not have any concurrency at all.
3. **Concurrency occurs only at suspension points: **no await inside a block → no interleave → no need for locks there.
4. **Locks should protect data across tasks, not coroutines: **lock where suspension is possible, not where the keyword async appears.

## Practical guidelines for real codebases

- **Audit where tasks are created: **every `asyncio.create_task()` is a concurrency boundary.
- **Scan critical sections for suspension points: **if there's no await inside the lock, the block is atomic relative to the event loop.
- **Prefer "compute outside, mutate inside": **compute values before acquiring the lock, then mutate quickly inside it.
- **Teach the difference explicitly: **a surprising number of experienced engineers haven't internalized coroutine vs task separation.

## Conclusion: Python async isn’t JavaScript async

Once you internalize that:

- JavaScript: async function → always a task
- C#: async → always a task
- Java (Loom's `VirtualThread`)): async → always a task
- **Python: async def → only a coroutine; task creation is explicit**

Then the whole model makes sense.

Python's await isn't a context switch. It's a structured control flow that *might* suspend.

That difference is why I didn't add more locks to my cache code. And it's why I now review Python async code by asking a much better question:

> "Where can this code actually interleave?"

That single question catches more bugs and eliminates more unnecessary complexity than any blanket rule about locking in async systems.
