Skip to content

Scheduling Callbacks with loop.call_soon vs asyncio.create_task

Most asyncio code reaches for asyncio.create_task() whenever it wants something to happen "later, on the loop". Often that is right. But a task is a full coroutine driver — it has a result, a cancellation state, a name, a context copy and a slot in the set of live tasks — and a lot of what gets wrapped in a task is a three-line synchronous function that never awaits. For that work loop.call_soon() schedules a plain callback: in a microbenchmark it cost 0.48 µs per scheduled call against 4.6 µs to create, run and gather a no-op task, and the handle it allocates is about half the size. The cost difference is rarely the deciding factor; the semantics are. This guide covers both.

Prerequisites

1. See that both land on the same queue

call_soon(fn, *args) appends a Handle to the loop's ready deque. create_task(coro) creates a Task, and the Task's constructor also calls call_soon — to schedule its own first __step. Both go to the back of the same FIFO, so their relative order is the order you scheduled them in:

import asyncio


async def main() -> None:
    loop = asyncio.get_running_loop()
    order: list[str] = []

    async def coro() -> None:
        order.append("task")

    loop.call_soon(order.append, "soon-before")
    asyncio.create_task(coro())
    loop.call_soon(order.append, "soon-after")
    await asyncio.sleep(0)
    print(order)


asyncio.run(main())
# ['soon-before', 'task', 'soon-after']

The task's body ran between the two callbacks because its first step was queued between them. This only holds for a task's first step: after its first await, each resumption is queued by whatever woke it.

With the eager task factory installed, that changes: the task's first step runs synchronously inside create_task(), before soon-before has a chance to run. Code that relies on create-then-callback ordering breaks when someone turns eager tasks on.

Verify: run the snippet, then add loop.set_task_factory(asyncio.eager_task_factory) and observe task move to the front.

Two ways onto the same ready queue A flow of 4 stages. Two ways onto the same ready queue call_soon(fn) allocates a Handle create_task(coro) Task schedules __step ready deque FIFO, shared by both _run_once pops and runs each entry A task is a callback that re-schedules itself after every await; a Handle runs exactly once.

2. Use call_soon for synchronous follow-up work

call_soon is the right tool when the work is synchronous, short, and you want it to run after the current step finishes rather than right now. Typical cases: completing a future from inside a protocol callback, notifying listeners after state has been fully updated, or breaking recursion in an event dispatcher.

import asyncio
from collections.abc import Callable


class Emitter:
    """Notify listeners after the current mutation has finished, never mid-update."""

    def __init__(self) -> None:
        self._listeners: list[Callable[[str], None]] = []
        self._loop = asyncio.get_running_loop()

    def subscribe(self, fn: Callable[[str], None]) -> None:
        self._listeners.append(fn)

    def emit(self, event: str) -> None:
        for fn in list(self._listeners):
            self._loop.call_soon(fn, event)     # deferred: listener cannot re-enter emit()

Deferring the listener calls means a listener that triggers another emit() does not recurse into the half-finished loop over listeners; it just appends more callbacks. That is the same reason asyncio itself uses call_soon to run a future's done-callbacks.

Two properties to remember. A callback has no result — there is nothing to await, so if the caller needs to know when it ran, it needs a future. And a callback cannot be awaited for cancellation: handle.cancel() removes it from the queue if it has not run, and does nothing otherwise.

Verify: a listener that calls emit() again should see its events delivered on the next loop iteration, not nested inside the current call.

3. Use create_task when the work awaits or must be supervised

Anything that awaits needs a task. So does anything whose outcome someone must observe — a result, an exception, a completion time — or that must be cancellable mid-flight:

import asyncio

background: set[asyncio.Task] = set()


def start_refresh(key: str) -> asyncio.Task:
    task = asyncio.create_task(refresh(key), name=f"refresh:{key}")
    background.add(task)                     # strong reference until done
    task.add_done_callback(background.discard)
    return task


async def refresh(key: str) -> None:
    async with asyncio.timeout(5):
        data = await fetch(key)
        await store(key, data)

The strong reference matters: the loop only keeps a weak reference to tasks, as covered in preventing task garbage collection. A Handle from call_soon has no such problem — the ready queue holds it strongly until it runs.

Verify: asyncio.all_tasks() lists the task by name while it is running, and the background set is empty after it completes.

Handle versus Task, property by property A grid of 6 rows by 3 columns. Handle versus Task, property by property property call_soon Handle create_task Task cost to schedule and run 0.48 µs 4.6 µs object size 104 bytes 200 bytes can await no yes result or exception none stored on the task cancel after it starts not possible CancelledError at next await kept alive by the ready queue your reference only Measured on Python 3.14; the semantic rows matter far more than the cost rows.

4. Know where exceptions go

The two mechanisms report failures very differently, and this is where most bugs come from.

An exception raised by a call_soon callback is caught by the loop and passed straight to the loop's exception handler, which by default logs it at ERROR as Exception in callback … with a full traceback. Nobody else ever sees it, and the loop keeps running.

An exception raised inside a task is stored on the task. It is only reported if nobody retrieves it — and then only when the task object is garbage collected, as Task exception was never retrieved. With a strong reference held in a long-lived set, that message may never appear at all:

def _log_failure(task: asyncio.Task) -> None:
    if task.cancelled():
        return
    if (exc := task.exception()) is not None:
        log.error("background task %s failed", task.get_name(), exc_info=exc)


task = asyncio.create_task(refresh(key))
task.add_done_callback(_log_failure)          # report immediately, not at GC time

If you route all loop-level errors to one place by installing a custom exception handler, callback failures arrive there automatically; task failures only arrive if a done-callback or the GC path sends them.

Verify: raise inside a call_soon callback and inside a background task; confirm both failures appear in logs within the same loop iteration.

5. Respect the thread-safety rule

Neither call_soon nor create_task is thread-safe. Calling them from another thread can corrupt the ready queue or, more commonly, schedule work that does not run until something else happens to wake the loop — the selector is sleeping and nobody wrote to its self-pipe. From other threads, use the _threadsafe variants:

import asyncio
import threading


def worker(loop: asyncio.AbstractEventLoop, results: asyncio.Queue) -> None:
    value = compute()                                         # in a plain thread
    loop.call_soon_threadsafe(results.put_nowait, value)      # wakes the loop
    fut = asyncio.run_coroutine_threadsafe(store(value), loop)
    fut.result(timeout=5)                                     # concurrent.futures.Future


async def main() -> None:
    loop = asyncio.get_running_loop()
    results: asyncio.Queue = asyncio.Queue()
    threading.Thread(target=worker, args=(loop, results), daemon=True).start()
    print(await results.get())

call_soon_threadsafe is call_soon plus a write to the loop's self-pipe so the selector wakes immediately. run_coroutine_threadsafe is the thread-safe create_task, returning a concurrent.futures.Future the thread can block on. The full pattern is in sending results from threads to an asyncio queue.

Verify: in debug mode, calling call_soon from a non-loop thread raises RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one.

Which scheduling call fits? A decision on Does the work await anything with 3 outcomes. Which scheduling call fits? Does the work await anything? no, and on the loop thread loop.call_soon sync, fire once yes, on the loop thread asyncio.create_task keep a reference either, from another thread the _threadsafe variants wake the selector Pick by what the work does and where the call comes from, not by habit.

Verification

You have the right split when:

  • Every create_task result is held somewhere — a set, a TaskGroup, or an attribute — and failures are reported by a done-callback.
  • Synchronous one-shot work uses call_soon, and none of it blocks for more than a fraction of a millisecond.
  • asyncio.all_tasks() is not cluttered with thousands of trivial tasks that never awaited.
  • Debug mode is clean: no Non-thread-safe operation errors and no Executing <Handle …> took warnings.

Diagnostic Hook: sample len(asyncio.all_tasks()) alongside the loop's ready-queue length (len(loop._ready) in a debug build or a test) every few seconds. A task count that tracks request rate rather than concurrency usually means trivial work is being wrapped in tasks; a ready queue that keeps growing means callbacks are being scheduled faster than the loop can drain them.

Pitfalls & edge cases

  • Blocking inside a callback. A call_soon callback runs on the loop thread like any task step; a 50 ms callback is a 50 ms stall.
  • Rescheduling yourself forever. A callback that calls call_soon(self) every time never lets the selector sleep and pins a core at 100%. Use call_later with a real delay.
  • Losing context. Both capture the current contextvars context by default; pass context= explicitly if the callback should run in a different one.
  • Relying on first-step ordering. It changes under eager tasks and under different loop implementations; if order matters, make it explicit with a future or an event.
  • Cancelling a handle that already ran. handle.cancel() returns silently; check handle.cancelled() if you need to know.

Frequently Asked Questions

What is the difference between loop.call_soon and asyncio.create_task?

call_soon schedules a plain synchronous callback to run once on the next loop iteration; it has no result and cannot await. create_task wraps a coroutine in a Task that can await, be cancelled mid-flight, and stores its result or exception. Both use the same ready queue.

Is call_soon faster than create_task?

Yes — about 0.48 µs per scheduled callback against 4.6 µs to create and run a no-op task on Python 3.14. The difference only matters in very hot paths; choose by semantics first.

Is loop.call_soon thread-safe?

No. From another thread use loop.call_soon_threadsafe, which also wakes the selector, or asyncio.run_coroutine_threadsafe for coroutines.

What happens if a call_soon callback raises?

The loop catches it and passes it to the loop's exception handler, which logs "Exception in callback" with a traceback by default. The loop keeps running and nothing else is notified.

Does create_task run the coroutine immediately?

Not by default: it schedules the first step on the ready queue, so the coroutine starts at the next loop iteration. With asyncio.eager_task_factory (Python 3.12+) it runs synchronously until its first await.