Python Module 15 – Async programming with asyncio
Context variables and thread-local data
Keep a connection per thread with threading.local, carry a request id through asyncio tasks with ContextVar, and see how tasks and threads inherit context.
What you will learn
- Store per-thread data such as a database connection with threading.local
- Use a ContextVar for per-task values such as a request id in async code
- Restore a context variable with a token used as a context manager
- Explain how new tasks, threads and executor jobs inherit context
- Add the current request id to every log record with a logging filter
Before you start
What you’ll learn · Before you start
What you will learn
- Store per-thread data such as a database connection with threading.local
- Use a ContextVar for per-task values such as a request id in async code
- Restore a context variable with a token used as a context manager
- Explain how new tasks, threads and executor jobs inherit context
- Add the current request id to every log record with a logging filter
Before you start
On this page
Two requests, r-17 and r-42, are served at the same time as asyncio task: A coroutine wrapped by asyncio.create_task() and scheduled on the running event loop. By default it starts the next time the code that is running pauses, runs alongside other tasks, and keeps the coroutine's result or exception for whoever awaits it. More in “Tasks, gather and running coroutines”, and both of their log lines say r-42. The id sat in one module-level variable, and the second request overwrote it while the first was waiting for its database. Values like this describe what a program is doing right now: the request being served, the signed-in user, an open transaction. Passing them through every call is noisy.
Python has two tools for them. threading.local() keeps a separate set of attributes for each thread, which suits code
that serves one request per thread. A contextvars.ContextVar keeps one value per
context (contextvars): In contextvars, a mapping from context variables to their values. Each thread has its own, asyncio gives every task a copy taken when the task is created, and copy_context() returns a copy of the current one., and asyncio runs every task in its own copy of the context, so a value set
in one task is invisible to the others even though they all share one thread. Use
thread-local data: Attributes of a threading.local() object, which each thread sees separately: a value one thread assigns is invisible to the others. The tasks of one event loop share its thread, and so share these attributes. for resources that belong to a thread, such as a database
connection, and context variable: A contextvars.ContextVar: a variable whose value is kept per context rather than once for the program. asyncio runs every task in its own copy of the context, so a value one task sets is invisible to the others, even on the same thread. for per-request values in async code, or in any
code that may become async.
A global breaks as soon as two requests overlap
Two requests, handled as two asyncio tasks, store their id first in a module-level variable and then in a
ContextVar. Each handler awaits once, the way it would await a database call:
import asyncio
from contextvars import ContextVar
current_request = "-" # one global for the whole program
request_id = ContextVar("request_id", default="-") # one value per context
def log_global(message):
print(f"[{current_request}] {message}")
def log_context(message):
print(f"[{request_id.get()}] {message}")
async def handle_with_global(rid, item):
global current_request
current_request = rid
await asyncio.sleep(0) # a database call: other tasks run meanwhile
log_global(f"loaded {item}")
async def handle_with_contextvar(rid, item):
request_id.set(rid)
await asyncio.sleep(0)
log_context(f"loaded {item}")
async def main():
print("with a global:")
async with asyncio.TaskGroup() as tg:
tg.create_task(handle_with_global("r-17", "cart"))
tg.create_task(handle_with_global("r-42", "invoice"))
print("with a ContextVar:")
async with asyncio.TaskGroup() as tg:
tg.create_task(handle_with_contextvar("r-17", "cart"))
tg.create_task(handle_with_contextvar("r-42", "invoice"))
print("main still sees:", repr(request_id.get()))
asyncio.run(main()) Output
with a global: [r-42] loaded cart [r-42] loaded invoice with a ContextVar: [r-17] loaded cart [r-42] loaded invoice main still sees: '-'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shared_global.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With the global, both lines claim to belong to request r-42. Task A stored r-17, paused at its await, and task B
overwrote the global before A wrote its log line. Nothing raised an error; the log was simply wrong, which is how this
bug appears in production, rarely and under load. With the ContextVar, each task logged its own id, and main()
still saw the default "-" afterwards, because request_id.set() changed the value only in the context of the task
that called it. Step through both runs and watch the value in the middle, written by both tasks and then copied for
each:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Two requests at once. main() starts two handlers in a TaskGroup. Task A serves request r-17 and loads a cart; task B serves r-42 and loads an invoice. Both keep the id in one module-level variable, current_request.
- A stores r-17, then awaits. Task A writes r-17 to the global and awaits asyncio.sleep(0), the stand-in for a database call. The await gives the event loop to the next ready task.
- B overwrites it. Task B runs while A waits and writes r-42 to the same variable. Nothing complains, because there is one name and B may rebind it.
- A logs the wrong id. A resumes and logs. log_global() reads the global, which now holds r-42, so the line says [r-42] loaded cart, although the cart belongs to r-17.
- B is right by luck. B logs [r-42] loaded invoice, right only because it wrote last. Both lines claim r-42 and nothing raised an error, which is how the bug shows up in production, rarely and under load.
- The same with a ContextVar. The second run keeps the id in request_id, a ContextVar. The TaskGroup gave each new task its own copy of the context, so a set() changes only the copy of the task that calls it.
- A sets its own copy. Task A calls request_id.set("r-17") and awaits. The value goes into A's copy of the context and nowhere else.
- B sets its own copy. Task B calls request_id.set("r-42") while A waits. This time the write lands in B's copy, so A's value stays r-17.
- Each logs its own id. After the same awaits, A logs [r-17] loaded cart and B logs [r-42] loaded invoice. main() still reads the default "-" afterwards, because a value set inside a task never reaches the code that created it.
Checkpoint
Check: A global shared by overlapping requests
Every task runs in a copy of the context
asyncio.create_task() copies the current context when it creates a task, and the task runs in that copy for its
whole life. The figure follows the second run above: main()’s context at the top, a copy for each task in the
middle, and main()’s value unchanged at the bottom.
Every task runs in its own copy of the context
Text description of the diagram
The diagram runs from top to bottom.
1. At the top, main() runs in a context where request_id has no value, so get() returns the default, "-". 2. create_task() takes a snapshot of that context for each new task. Task A calls set("r-17") and task B calls set("r-42"); each call changes only the task's own copy, so A reads r-17 and B reads r-42, whatever order they run in. 3. At the bottom, after both tasks have finished, main() still reads "-": a value set inside a task never reaches the task that created it.
Three rules follow from that. A task sees the values its creator had set before creating it. Whatever the task sets stays in its own copy and never reaches its parent. And the copy is A copy that holds the same objects as the original, not copies of them: a list in a copied context is the one list every copy reaches.: a list stored in a context variable is the same list object in every copy.
Key idea
A task’s copy of the context is taken when the task is created: it starts with what its creator had set, and what the task sets stays in the copy.
Checkpoint
Check: Each task's copy of the context
threading.local() follows threads, a ContextVar follows tasks
Thread-local data is tied to the thread, and every task of an event loop runs in the loop’s thread, so it cannot tell
two concurrent requests apart in async code. Context variables cover threads too, because each thread has a context
of its own. The standard library made the same move in Python 3.7: decimal keeps its active context in a context
variable, so decimal arithmetic uses the right precision inside each asyncio task. Answer the two questions for a value
of your own; the table marks the tool that fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Tool | Tasks sharing one thread | Worker threads | A new thread starts with | Undoing a value | Typical use |
|---|---|---|---|---|---|
| A module-level global Keeps one value per: the whole program | Poor one value for all of them: both log lines said r-42 | Poor every thread reads and writes the same value | Mixed the same value as every other thread | Mixed assign the old value back by hand | Mixed values that are the same for every request |
| threading.local() Keeps one value per: thread | Poor the tasks of a loop share its thread, so they share the attributes | Good each thread sees only the attributes it assigned | Mixed no attributes: nothing is passed on | Good del the attribute | Good a connection or buffer per worker thread |
| A ContextVar Keeps one value per: context: every asyncio task, and every thread | Good each task sees its own value: r-17 and r-42 | Good each thread has a context of its own | Mixed on the default build an empty context, unless it is given one | Good reset() with the token from set(), or a with block | Good a request id, a user or a trace id |
How the answers decide
- “Does the code run as asyncio tasks, or may it become async one day?” Yes, asyncio tasks on an event loop: rules out threading.local() and A module-level global. Every task of an event loop runs in the loop's one thread, so thread-local attributes and a global are both shared by every request in flight.
- “What does the value belong to?” A request: an id, a user, a trace: rules out A module-level global. A global holds one value for the whole program, so overlapping requests overwrite each other's ids without any error.
- “Does the code run as asyncio tasks, or may it become async one day?” No, it serves one request per thread and “What does the value belong to?” A request: an id, a user, a trace: adds a caveat to threading.local(). Thread-local data separates requests only while each request has a thread of its own, and stops doing so the day the code becomes async.
- “What does the value belong to?” A thread: a connection or a buffer: rules out A module-level global. A global is one value for every thread, so it cannot give each thread a resource of its own.
- “What does the value belong to?” A thread: a connection or a buffer: adds a caveat to A ContextVar. A resource such as a connection stays with the thread that opened it, which is what threading.local() is for; a context variable follows tasks and requests.
A request id in threading.local() in async code
state.request_id = rid in an async def handler is shared by every task on the loop’s thread, so after an await
the handler can read another request’s id. Keep the id in a module-level ContextVar and call
request_id.set(rid) instead.
Checkpoint
Check: threading.local() or a ContextVar
A token puts the old value back
set() returns a token (contextvars): The object ContextVar.set() returns. It remembers the value the variable had before, and var.reset(token) puts that value back, once. Since Python 3.14 a token is also a context manager, so a with block resets the variable at its end. that remembers the previous value. Since Python 3.14 the
token is also a context manager: An object with __enter__() and __exit__() methods. The with statement calls __enter__() before its block and __exit__() after it, however the block ends, so a file is closed or a lock released even after an error. More in “Context managers and the with statement”, so a with block sets a value and puts the old one
back at its end, even when an exception leaves the block. Step through the program; each show() prints the value of
that moment:
Program · token_scope.py
from contextvars import ContextVar, Token
request_id = ContextVar("request_id", default="-")
def show(where):
print(f"{where:<22} request_id = {request_id.get()}")
show("before")
with request_id.set("r-7") as token: # a Token is a context manager (3.14)
show("inside")
print(f"{'token.old_value':<22} is Token.MISSING: {token.old_value is Token.MISSING}")
with request_id.set("r-7/retry"):
show("nested")
show("after the nested with")
show("after")
token = request_id.set("r-8") # the same, written out by hand
try:
show("inside try")
finally:
request_id.reset(token)
show("after reset")
try:
request_id.reset(token)
except RuntimeError as e:
print("resetting with it again raises", type(e).__name__) Variables
- changed in this step
Printed so far
Nothing printed yetbefore request_id = - inside request_id = r-7 token.old_value is Token.MISSING: True nested request_id = r-7/retry after the nested with request_id = r-7 after request_id = - inside try request_id = r-8 after reset request_id = - resetting with it again raises RuntimeError
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 23 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
from contextvars import ContextVar, TokenloadsContextVarandToken. - Line 3.
request_id = ContextVar("request_id", default="-")setsrequest_idto<ContextVar object>. - Line 6.
def show(where):makes the functionshow. Its body runs only when it is called. - Line 10.
show("before")printsbefore request_id = -. - Line 11.
with request_id.set("r-7") as token:runs.set()returned a token, and thewithblock holds it astoken. Until the block ends,request_idis r-7. - Line 12.
show("inside")printsinside request_id = r-7. - Line 13.
print(f"{'token.old_value':<22} is Token.MISSING: {token.old_value is Token.MISSING}")printstoken.old_value is Token.MISSING: True. Key point The token remembers the value from before itsset(). The variable had none, sotoken.old_valueis the markerToken.MISSING. - Line 14.
with request_id.set("r-7/retry"):runs. - Line 15.
show("nested")printsnested request_id = r-7/retry. The nested block set r-7/retry over r-7. Its own token remembers r-7, so the end of this block puts r-7 back. - Line 14.
with request_id.set("r-7/retry"):runs. - Line 16.
show("after the nested with")printsafter the nested with request_id = r-7. The nested block has ended and r-7 is back. Each block restores the value it found, so blocks can nest. - Line 11.
with request_id.set("r-7") as token:runs. The outer block ends here, and its token puts back what was there before itsset(): no value at all. - Line 17.
show("after")printsafter request_id = -. The outer block has ended too. The variable has no value again, soget()returns the default, "-". - Line 19.
token = request_id.set("r-8")setstokento<Token object>(it was<Token object>). - Line 20.
try:runs. - Line 21.
show("inside try")printsinside try request_id = r-8. - Line 23.
request_id.reset(token)leavesrequest_idas it was. Key pointreset(token)in afinallyblock is what thewithstatement does for you, and the way to write it on Python 3.13 and older. - Line 24.
show("after reset")printsafter reset request_id = -. - Line 25.
try:runs. - Line 26.
request_id.reset(token)leavesrequest_idas it was. - Line 27. Line 27 runs.
- Line 28.
print("resetting with it again raises", type(e).__name__)printsresetting with it again raises RuntimeError.eno longer exists. That was the last line, so the program ends. A token works once: the secondreset()with the same token raisedRuntimeError.
Inside the outer block the id was r-7; the nested block replaced it, and the end of that block put r-7 back.
token.old_value is Token.MISSING because the variable had no value before the first set(). The try and
finally form at the end is what the with block does for you, and the form to use on Python 3.13 and older. A
token works once: resetting with it a second time raises RuntimeError.
Four more tools complete the picture. contextvars.copy_context() takes a snapshot of the current context, and
Context.run() calls a function inside such a snapshot. threading.Thread(context=...), new in 3.14, sets the
context a thread starts with. asyncio.to_thread() passes the caller’s context to its thread, while
loop.run_in_executor() passes none. A logging.Filter can copy a context variable into every log record.
Checkpoint
Check: Undoing a value with its token
Check yourself
One question at a time on the four ideas above, the one you know least first.
Practice that adapts to you
Practise this lesson
10 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
How context flows into tasks, threads and executors, with four failures reproduced Pro: opens with a Pro, Premium or Ultimate pass
Context reaches a task when the task is created, reaches a thread only when someone passes it, and reaches an executor job only by hand. This part runs every case, reproduces the four failures these rules cause and shows the fix for each.
In this part:
- When a task takes its copy, traced step by step
- Threads and executors: three failures reproduced and fixed
- A mutable value shared by every copy, reproduced
3 diagrams · 4 runnable examples (Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Per-thread resources and request ids in every log line, measured Pro: opens with a Pro, Premium or Ultimate pass
threading.local() still has a job in threaded code, and a context variable is the natural home of a request id in
logs. This part builds both, measures what a lookup of each costs and ends with an exercise.
In this part:
- One database connection per worker thread, counted
- Request ids in every log line, from any library
- What a lookup costs, measured
- A worker that serves many requests must reset the context, reproduced
- Exercise: a request context and a log line that knows it
- A request context and a log line that knows it
2 diagrams · 4 runnable examples (Python) · 1 exercise with sample tests · about 12 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is the difference between threading.local() and a contextvars.ContextVar, and which one should asyncio code use?
threading.local() gives each thread its own set of attributes: a value assigned in one thread is invisible in the others. A ContextVar stores a value in the current context, and Python keeps a context per thread and, in asyncio, a copy per task, taken when the task is created. In threaded code the two behave alike. In asyncio they differ, because all the tasks of an event loop run in the same thread: with threading.local(), two concurrent requests overwrite each other's values, while with a ContextVar each task sees only what it, or the code that created it, set. So asyncio code keeps per-request values such as a request id or the current user in context variables, created once at module level, and keeps threading.local() for resources that belong to a thread, such as a database connection in a thread pool.
-
Mid to senior
Under load, about one log line in a thousand from an async web service carries another request's id. Each handler stores the id in a module-level variable as its first step, and the log formatter reads it. Explain the bug, and fix it so that every line, including lines that libraries log, carries the right id.
-
Mid to senior
A tenant id kept in a ContextVar is visible inside a function run with asyncio.to_thread(), but the same function run with loop.run_in_executor() sees the default value. A teammate proposes a global dict keyed by thread id. Explain the difference, and what you do instead.
Key takeaways
- A global holds one value for the whole program, so concurrent requests overwrite each other’s values without any error.
- Every asyncio task runs in a copy of the context taken when it is created, so what it sets never reaches its creator.
threading.local()keeps attributes per thread: right for per-thread resources, wrong for asyncio tasks, which share their loop’s thread.with var.set(value):sets a value for one block and restores the old one afterwards, on Python 3.14 and newer.
Terms in this lesson
- context (contextvars)
- In contextvars, a mapping from context variables to their values. Each thread has its own, asyncio gives every task a copy taken when the task is created, and copy_context() returns a copy of the current one.
- context variable
- A contextvars.ContextVar: a variable whose value is kept per context rather than once for the program. asyncio runs every task in its own copy of the context, so a value one task sets is invisible to the others, even on the same thread.
- thread-local data
- Attributes of a threading.local() object, which each thread sees separately: a value one thread assigns is invisible to the others. The tasks of one event loop share its thread, and so share these attributes.
- token (contextvars)
- The object ContextVar.set() returns. It remembers the value the variable had before, and var.reset(token) puts that value back, once. Since Python 3.14 a token is also a context manager, so a with block resets the variable at its end.
References
- contextvars: Context Variables (Python Software Foundation)
- threading: thread-local data (Python Software Foundation)
- threading.Thread and its context parameter (Python Software Foundation)
- What's New In Python 3.14: contextvars (Python Software Foundation)
- What's New In Python 3.14: free-threaded mode improvements (Python Software Foundation)
- Command line and environment: -X thread_inherit_context (Python Software Foundation)
- asyncio: creating tasks, and running in threads (Python Software Foundation)
- asyncio event loop: loop.run_in_executor() (Python Software Foundation)
- logging: Filter objects (Python Software Foundation)
- sqlite3.connect() and check_same_thread (Python Software Foundation)
- What's New In Python 3.7: contextvars (Python Software Foundation)
- PEP 567: Context Variables (Python Software Foundation)
- timeit: measure execution time of small code snippets (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress