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 contextasyncio.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 |
|
Running sync code in a thread |
|
Function that’s often scheduled as a task |
|
Performance
Context copying via contextvars.copy_context() is fast (~100ns), negligible compared to task creation overhead.