Async in Python vs JavaScript: What Changes When You Switch

Switching from JavaScript to Python, it’s easy to assume the two languages are basically the same with different syntax. Both are dynamically typed, both treat functions as first-class objects, both support OOP. The syntax differs but feels familiar. Lately I’ve been doing more Python, so I started paying attention to the small differences. Today I want to look at how async handling actually differs.

Defining an async function

In Python you define an async function much like in JavaScript:

async def foo():
    ...

The difference is that the async code inside won’t actually run until you await it. Unlike Node.js, async doesn’t wrap the body in a Promise that starts executing immediately. Instead, it creates a coroutine that runs only once you await it.

So this won’t create a user:

async def foo():
    create_user()

But this will:

async def foo():
    await create_user()

You also need to kick everything off with asyncio.run:

asyncio.run(foo())

Does calling asyncio.run create a new event loop?

Yes. Call it twice and you get two event loops. In an application you want a single main asyncio.run call and you use async/await inside it. Don’t reach for asyncio.run to fire off many small async functions one by one.

In older Node, before top-level await, we used an IIFE to do the same thing. asyncio.run plays a similar role:

(async () => {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts(),
  ]);
  console.log(user, posts);
})();

Fire and forget

In JavaScript you can fire an async function and forget about it, or do other work and await it later:

async function foo() {
    const promise = createUser();
    // other code
    const result = await promise;
}

Because of how Python handles coroutines, you have to convert the coroutine into a Task to get the same effect. You do that with asyncio.create_task:

async def foo():
    task = asyncio.create_task(create_user())
    # other code
    result = await task

Here’s how that plays out on the event loop. foo schedules the task, keeps doing its own work, then parks on await task until the background work returns.

▸ LIVE DEMO
asyncio.create_task — fire and forget
t = 0.00s
async def foo():
task = asyncio.create_task(create_user())
do_other_work()
result = await task
RUNNING
▸ foo
WAITING
(none)
READY
(none)
DONE
(none)
foo() starts running
0.0 / 3.0s
[0.00] foo() entered

Running functions concurrently

In JavaScript, when you want to make multiple I/O calls in parallel, you reach for Promise.all. You pass it an array of Promises and it returns one Promise that resolves when all of them succeed:

const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts(),
]);

In Python the built-in equivalent is asyncio.gather:

import asyncio

user, posts = await asyncio.gather(
    fetch_user(),
    fetch_posts(),
)

Watching it run, both fetches go from ready to waiting almost immediately and resolve independently. Total time is roughly the slower of the two, not their sum.

▸ LIVE DEMO
asyncio.gather — running concurrently
t = 0.00s
user, posts = await asyncio.gather(
fetch_user(),
fetch_posts(),
)
RUNNING
(none)
WAITING
(none)
READY
▸ fetch_user
▸ fetch_posts
DONE
(none)
gather() schedules both coroutines
0.0 / 2.4s
[0.00] fetch_user, fetch_posts → ready

Delaying without blocking the event loop

In JavaScript, when you want to wait some time, you reach for setTimeout(fn, ms), which runs fn after ms milliseconds. setTimeout registers a callback that the event loop will invoke later.

In Python you use await asyncio.sleep(seconds). It pauses the current coroutine for the given number of seconds without blocking the whole event loop. After await, the coroutine yields control back to the loop, which can resume it later.

The practical effect:

  • In JS, code after setTimeout keeps running immediately.
  • In Python, code after await asyncio.sleep(...) in the same coroutine waits, but other tasks and coroutines can keep going.

Example:

console.log("A");
setTimeout(() => console.log("B"), 1000);
console.log("C");

Output: A, C, then B.

import asyncio

async def main():
    print("A")
    await asyncio.sleep(1)
    print("B")
    print("C")

asyncio.run(main())

Output: A, then B after a second, then C.

Press play and watch fast finish while slow is still parked on sleep(2). That’s the event loop picking up whichever task is ready, instead of blocking on the sleeping one.

▸ LIVE DEMO
asyncio.sleep — yielding without blocking
t = 0.00s
async def slow():
await asyncio.sleep(2)
print('slow done')
async def fast():
print('fast done')
RUNNING
(none)
WAITING
▸ slow [sleep(2)]
READY
▸ fast
DONE
(none)
slow() starts and immediately hits sleep(2)
0.0 / 2.4s
[0.00] slow awaiting sleep(2)

Wrapping up

Python feels very similar to JavaScript when it comes to async functions, but there are small differences that meaningfully change how those functions behave and how you have to reason about them. It’s worth being aware of them so you’re not caught off guard when something that worked out of the box in JavaScript needs a slightly different approach in Python.

Read more