Python Basics: Isolating Request State with contextvars
A logging helper may need the current request identifier even when several requests share the same event loop. A normal global variable would let one request overwrite another’s value. ContextVar provides context-local state without passing that identifier through every helper call.
The examples keep the variable declarations at module level and store immutable strings. Each file is independent: the first explores restoration, the second copies a context, and the third runs two asynchronous requests.
Prepare a scratch directory
These three standalone examples were tested on Linux with Python 3.12.14. The examples require Python 3.7 or newer, when contextvars and asyncio.run were introduced. They use explicit reset tokens rather than newer token context-manager syntax. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Keep assertions enabled by running without Python’s -O option.
Create a fresh directory and keep using the same terminal:
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1Save each named source file here as UTF-8.
Avoid source filenames such as contextvars.py or asyncio.py. Request identifiers and currencies are invented examples; no logging service, payment system, or network is involved.
1. Restore an outer value after a failure
Save as restore_request_context.py:
from contextvars import ContextVar
request_id = ContextVar("request_id", default="outside")
outer_token = request_id.set("request-17")
try:
print("Request:", request_id.get())
inner_token = request_id.set("preview-17")
try:
print("Preview:", request_id.get())
raise ValueError("preview failed")
except ValueError:
print("Preview failure: handled")
finally:
request_id.reset(inner_token)
assert request_id.get() == "request-17"
print("Restored request:", request_id.get())
finally:
request_id.reset(outer_token)
assert request_id.get() == "outside"
print("After request:", request_id.get())Run it:
python3 restore_request_context.pyOutput:
Request: request-17
Preview: preview-17
Preview failure: handled
Restored request: request-17
After request: outsideset returns a token describing the previous value. The two finally blocks restore the matching tokens in reverse order, so a failed preview cannot leave its identifier attached to the surrounding request. After the request ends, get returns the variable’s default.
Use each token with the variable and context that created it, and reset it only once. The explicit nested structure makes ownership easy to follow. Assigning a guessed default in cleanup would lose the real outer value if this helper were called from another context.
2. Run a preview in a copied context
Save as isolate_preview_context.py:
from contextvars import ContextVar, copy_context
currency = ContextVar("currency", default="USD")
currency.set("EUR")
preview = copy_context()
def change_preview():
assert currency.get() == "EUR"
currency.set("JPY")
return currency.get()
preview_value = preview.run(change_preview)
assert preview_value == "JPY"
assert currency.get() == "EUR"
assert preview.run(currency.get) == "JPY"
print("Preview:", preview_value)
print("Caller:", currency.get())
print("Preview on next run:", preview.run(currency.get))Run it:
python3 isolate_preview_context.pyOutput:
Preview: JPY
Caller: EUR
Preview on next run: JPYThe preview begins with EUR, then changes its own binding to JPY. Its change survives a later call through preview.run, while the caller continues to see EUR. Treat a saved Context as an environment that can retain state across runs.
The copy separates variable bindings, not the internals of arbitrary objects. A list stored as a value can still be shared between contexts and mutated by both. Immutable values, as used here, keep the isolation boundary straightforward.
3. Keep asynchronous request identifiers separate
Save as isolate_async_request_ids.py:
import asyncio
from contextvars import ContextVar
request_id = ContextVar("request_id", default="outside")
async def handle_request(identifier):
token = request_id.set(identifier)
try:
await asyncio.sleep(0)
return f"handled {request_id.get()}"
finally:
request_id.reset(token)
async def main():
token = request_id.set("coordinator")
try:
results = await asyncio.gather(
handle_request("request-A"), handle_request("request-B"))
assert results == ["handled request-A", "handled request-B"]
assert request_id.get() == "coordinator"
print("Results:", results)
print("Parent:", request_id.get())
finally:
request_id.reset(token)
asyncio.run(main())
assert request_id.get() == "outside"
print("After run:", request_id.get())Run it:
python3 isolate_async_request_ids.pyOutput:
Results: ['handled request-A', 'handled request-B']
Parent: coordinator
After run: outsidegather schedules the request coroutines as tasks. Each sets an identifier, yields once, then reads it back. The results remain distinct even though their execution overlaps on one event loop. gather returns results in argument order, so the printed list is stable.
The coordinator’s value is unchanged, and the final outside check confirms that asyncio.run did not leak the request state into its caller. sleep(0) is only a deliberate scheduling point here; the example performs no remote request.
Prefer ordinary parameters when they make dependencies clearer. Context-local state is useful for cross-cutting metadata such as request identifiers, but it should not become a hidden container for all application data. These examples demonstrate asyncio task isolation, not automatic context transfer to arbitrary new threads.
Clean up
In the same terminal, remove only these source files, return to the original directory, and remove the now-empty scratch directory:
rm -- restore_request_context.py isolate_preview_context.py isolate_async_request_ids.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirIf rmdir reports that anything remains, inspect the directory rather than deleting unknown files.
References
Acknowledgements
This post was created with assistance from AI.