thread-local-storage
Python
multithreading
programming
concurrency

Thread local storage in Python

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Thread-local storage lets each thread keep its own private state without manually passing that state through every function call. In Python, the standard tool for this is threading.local(), which creates an object whose attributes are isolated per thread even though the object itself is shared.

Create a Thread-Local Object

The core API is small. You create one local object and store attributes on it as if it were a normal instance.

python
1import threading
2import time
3
4thread_state = threading.local()
5
6def worker(name: str) -> None:
7    thread_state.user = name
8    time.sleep(0.1)
9    print(threading.current_thread().name, thread_state.user)
10
11threads = [
12    threading.Thread(target=worker, args=("alice",), name="T1"),
13    threading.Thread(target=worker, args=("bob",), name="T2"),
14]
15
16for t in threads:
17    t.start()
18for t in threads:
19    t.join()

Even though both threads use the same thread_state object, each thread sees its own user value.

Why It Exists

Thread-local storage is useful when:

  • you need request-specific or task-specific state in threaded code
  • passing a context object through many layers would be noisy
  • the state should never be shared across threads

Common examples include request IDs, database-session handles, authentication context, and logging metadata in threaded applications.

The main benefit is convenience. The data feels globally reachable inside the thread, but it is not shared with other threads.

Practical Example: Per-Thread Request Context

A logging or tracing context is a classic use case.

python
1import threading
2
3request_context = threading.local()
4
5def set_request_id(request_id: str) -> None:
6    request_context.request_id = request_id
7
8def log(message: str) -> None:
9    request_id = getattr(request_context, "request_id", "unknown")
10    print(f"[request_id={request_id}] {message}")
11
12def handle_request(request_id: str) -> None:
13    set_request_id(request_id)
14    log("starting request")
15    log("finishing request")
16
17threading.Thread(target=handle_request, args=("req-101",)).start()
18threading.Thread(target=handle_request, args=("req-202",)).start()

Each thread logs with its own request ID without needing to thread that value through every helper function.

How It Works Conceptually

threading.local() does not clone the object per thread. Instead, it stores a separate attribute dictionary for each thread behind the scenes. That is why setting thread_state.user in one thread does not affect another thread's view of the same attribute.

This behavior is convenient, but it also means you should treat thread-local values as context state, not as a replacement for explicit program structure. Hidden context can become hard to reason about if it spreads everywhere.

Thread-Local Storage Is Not for Async Code

One important limitation is that thread-local storage tracks threads, not asynchronous tasks. In asyncio code, many logical tasks may run on the same thread, so threading.local() is usually the wrong abstraction.

For async task-local state, use contextvars instead.

python
1import asyncio
2import contextvars
3
4request_id_var = contextvars.ContextVar("request_id", default="unknown")
5
6async def handler(request_id: str) -> None:
7    request_id_var.set(request_id)
8    await asyncio.sleep(0.1)
9    print(request_id_var.get())
10
11asyncio.run(asyncio.gather(handler("req-a"), handler("req-b")))

This distinction matters a lot in modern Python services where concurrency often comes from async code rather than traditional threads.

Thread Pools Need Extra Care

If your application uses a thread pool, remember that worker threads are reused. That means thread-local state can accidentally persist into a later task if you do not reset it explicitly.

Setting a value at task start and clearing it at task end is a good habit when the data is sensitive or task-specific.

Common Pitfalls

One common mistake is assuming thread-local state automatically resets between reused worker threads. In thread pools, it does not. Old values can leak into later work unless you overwrite or clear them.

Another is using thread-local storage for data that should really be passed explicitly. Hidden context can make testing and debugging harder if too much code silently depends on it.

Developers also sometimes try to use threading.local() in async code and get confusing behavior because there is only one thread even though many tasks are active.

Finally, remember that thread-local storage provides isolation, not synchronization. It does not solve shared-state coordination problems between threads because, by design, the values are not shared.

Summary

  • 'threading.local() gives each thread its own isolated attribute storage.'
  • It is useful for per-thread context such as request IDs or session handles.
  • The same local object can hold different values in different threads.
  • Thread-local storage is not the right tool for asyncio; use contextvars there.
  • Clear or overwrite thread-local state when using reusable thread pools.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.