Sometimes threads shouldn't share. Give each one its own private copy of an object with threading.local().
Threads sharing memory is usually the point. But some objects are actively hostile to
being touched by two threads at once โ a database connection, a requests.Session, a
socket, a cursor. Sharing one across threads corrupts its internal state: interleaved reads/writes on
the same wire, half-parsed responses, "connection already in use" errors.
# โ One shared connection for all threads โ not thread-safe
import sqlite3, threading
conn = sqlite3.connect("app.db", check_same_thread=False) # forcing it open is a trap
def run_query(q):
return conn.execute(q).fetchall() # two threads here โ interleaved I/O on one socket
What you actually want: each thread gets its own connection, but the code that uses it stays simple โ no passing the connection through every function.
threading.local()threading.local() object. It looks like a
normal object with attributes, but each thread sees its own independent set of attributes.
Thread A's data.conn and Thread B's data.conn are different objects, even
though the code reads the exact same variable.
The idiom is to check whether this thread has initialized its copy yet, and create it lazily the first time:
import sqlite3, threading
_local = threading.local()
def get_conn():
# hasattr is per-thread: True only if THIS thread already made one
if not hasattr(_local, "conn"):
_local.conn = sqlite3.connect("app.db") # one connection per thread, created lazily
print(f"{threading.current_thread().name} opened a new connection")
return _local.conn
def run_query(q):
return get_conn().execute(q).fetchall()
threads = [
threading.Thread(target=lambda: run_query("SELECT 1"), name=f"w{i}")
for i in range(3)
]
for t in threads: t.start()
for t in threads: t.join()
# Prints THREE "opened a new connection" lines โ one per thread.
_local is a single global
object, but attribute access is routed to a per-thread slot behind the scenes. You never coordinate
and you never lock โ because nothing is actually shared.
The same _local name resolves to a different value in each thread:
Because each thread has its own conn, three threads run three queries truly concurrently
(I/O overlaps, GIL released during the wait) with zero locking โ the best of both
worlds compared to the shared-connection-plus-lock version.
requests.Session (connection pooling, cookies) is
not designed to be hammered by multiple threads. Give each thread its own session.g and request
are built on this idea.)# Per-thread requests.Session โ reuses connections within a thread, never across
import requests, threading
_local = threading.local()
def session():
if not hasattr(_local, "s"):
_local.s = requests.Session()
return _local.s
def fetch(url):
return session().get(url, timeout=5).status_code
A ThreadPoolExecutor (Pattern 4) does not create a fresh thread per task
โ it keeps a small set of worker threads alive and feeds them task after task. Thread-local state set
by one task persists when the same worker picks up the next task. That's great for
caching a connection, and dangerous for anything task-specific.
import threading
from concurrent.futures import ThreadPoolExecutor
_local = threading.local()
def handle(user):
_local.user = user # set per-task state...
# ...forgot to clear it, or an early return skips cleanup...
return getattr(_local, "user", None)
with ThreadPoolExecutor(max_workers=1) as ex: # 1 worker โ guaranteed reuse
print(ex.submit(handle, "alice").result()) # alice
# Next task reuses the SAME thread. If handle didn't overwrite first thing,
# it would still see 'alice'. With per-task state this is a real leak class.
How to handle it:
finally. If you must store per-task context, clear it at
the end of every task: try: ... finally: _local.__dict__.clear().initializer=. Set up per-worker resources once when
the worker starts, not per task: ThreadPoolExecutor(initializer=setup).contextvars for per-request/per-task context โ it's the modern
replacement that also works with asyncio, and its scoping is explicit rather than leaking across
reused threads.The honest alternative to a thread-local is often the simplest one: pass the object in. Explicit arguments have no hidden global state, no reuse-leak class of bugs, and are trivial to test.
threading.local() | Pass as argument | |
|---|---|---|
| Coupling | Hidden global โ spooky action at a distance | Explicit in the signature |
| Deep call stacks | โ No plumbing through every function | โ Must thread it everywhere |
| Pool reuse leaks | โ Real risk โ state survives tasks | โ Impossible โ nothing persists |
| Testability | Harder (global to reset) | Easy (just call with a value) |
threading.local() when you
genuinely can't (framework middleware setting context for code you don't own, or a resource that
must be one-per-thread like a DB connection).
| Concept | Takeaway |
|---|---|
threading.local() | One object; each thread sees its own private attributes |
| Use it for | Non-thread-safe resources: connections, sessions, cursors |
| Lazy init | if not hasattr(local, "x"): local.x = ... |
| Pool danger | Threads are reused โ thread-locals leak state across tasks |
| Per-request context | Prefer contextvars (explicit scope, asyncio-safe) |
| Simplest option | If you can, just pass the state as an argument |