Async Context Propagation

This guide explains how to preserve logging context across async boundaries in Python.

The Problem

Python’s contextvars propagate correctly across await boundaries, but not automatically into:

  • asyncio.create_task() - new tasks may not inherit context (pre-Python 3.11)

  • ThreadPoolExecutor / run_in_executor() - threads don’t inherit context

  • asyncio.gather() with tasks created separately

This means request IDs, user IDs, and trace context can silently vanish in background tasks.

Solution: Context Propagation Helpers

Fapilog provides three helpers to explicitly preserve context:

from fapilog import (
    create_task_with_context,
    run_in_executor_with_context,
    preserve_context,
)

create_task_with_context

Use this when spawning background tasks that need access to the current logging context.

import asyncio
import fapilog

async def background_work():
    logger = fapilog.get_logger()
    logger.info("background task")  # includes request_id from parent

async def main():
    logger = fapilog.get_logger().bind(request_id="req-123")
    logger.info("main task")

    # Without helper - context may be lost
    task1 = asyncio.create_task(background_work())

    # With helper - context explicitly preserved
    task2 = fapilog.create_task_with_context(background_work())

    await asyncio.gather(task1, task2)

With Task Names

task = fapilog.create_task_with_context(
    background_work(),
    name="process-order-123"
)

run_in_executor_with_context

Use this when running synchronous code in a thread pool while preserving context.

import asyncio
from concurrent.futures import ThreadPoolExecutor
import fapilog

def sync_work():
    # Context variables from caller are available here
    logger = fapilog.get_logger()
    logger.info("sync work in thread")  # includes request_id

async def main():
    logger = fapilog.get_logger().bind(request_id="req-456")
    executor = ThreadPoolExecutor(max_workers=2)

    # Context preserved in the executor thread
    await fapilog.run_in_executor_with_context(executor, sync_work)

    # Also works with the default executor (None)
    await fapilog.run_in_executor_with_context(None, sync_work)

Passing Arguments

def process_item(item_id: str, priority: int) -> dict:
    logger = fapilog.get_logger()
    logger.info("processing", item_id=item_id)
    return {"status": "done"}

result = await fapilog.run_in_executor_with_context(
    executor,
    process_item,
    "item-123",
    priority=1,
)

preserve_context Decorator

Use this decorator on async functions that may be scheduled as tasks, ensuring they always preserve context from their call site.

import asyncio
import fapilog

@fapilog.preserve_context
async def worker():
    logger = fapilog.get_logger()
    logger.info("worker")  # has parent context

async def main():
    logger = fapilog.get_logger().bind(user_id="user-789")

    # Even when scheduled as task, context is preserved
    await asyncio.gather(worker(), worker())

    # Also works with create_task
    task = asyncio.create_task(worker())
    await task

Context Isolation

Each task gets a copy of the context. Modifications in child tasks don’t affect the parent:

import contextvars
import fapilog

request_id = contextvars.ContextVar("request_id")

async def child_task():
    # This modification is local to the child
    request_id.set("child-override")

async def main():
    request_id.set("parent-value")

    task = fapilog.create_task_with_context(child_task())
    await task

    # Parent still sees original value
    assert request_id.get() == "parent-value"

FastAPI Integration

Context helpers work well with FastAPI background tasks:

from fastapi import FastAPI, BackgroundTasks
import fapilog

app = FastAPI()

async def send_notification(user_id: str):
    logger = fapilog.get_logger()
    # request_id from the request context is available here
    logger.info("sending notification", user_id=user_id)

@app.post("/orders")
async def create_order(background_tasks: BackgroundTasks):
    logger = fapilog.get_logger().bind(request_id="req-abc")

    # Use create_task_with_context for context-aware background work
    task = fapilog.create_task_with_context(
        send_notification("user-123")
    )

    return {"status": "created"}

When to Use Each Helper

Scenario

Helper

Spawning a background task

create_task_with_context()

Running sync code in a thread

run_in_executor_with_context()

Function that’s often scheduled as a task

@preserve_context decorator

Performance

Context copying via contextvars.copy_context() is fast (~100ns), negligible compared to task creation overhead.