Assessing Python Dict Thread Safety
Rule of thumb:
- GIL-enabled build: single dict method calls (
d[k] = v,d.get(k),d.pop(k),len(d)) are atomic and safe across threads. Multi-step sequences (check-then-act) are NOT safe. - Free-threaded build (no-GIL): single dict method calls are still atomic/thread-safe (CPython guarantees internal consistency), but multi-step sequences are still NOT safe without external locking.
Python# UNSAFE on both builds — race between check and act if key not in d: d[key] = compute() # SAFE — use atomic single-call alternatives d.setdefault(key, compute()) # note: compute() still runs even if key exists # SAFE for actual atomicity guarantee — use a lock for compound logic with lock: if key not in d: d[key] = compute()
- Identify the build target. Determine if the code must run correctly under free-threaded CPython (3.13+,
Py_GIL_DISABLED), standard GIL builds, or both. Thread-safety guarantees differ subtly. - Classify each dict operation:
- Single atomic operation (
__getitem__,__setitem__,__delitem__,__contains__,get,pop,setdefault,updatewith a single dict/iterable argument,len) → safe from corruption in both build types. - Compound/check-then-act sequences (read-modify-write across multiple statements, iterating while another thread mutates) → unsafe in both build types.
- Single atomic operation (
- Check iteration patterns. Iterating over a dict (
for k in d,.keys(),.values(),.items()) while another thread mutates it can raiseRuntimeError: dictionary changed size during iterationon GIL builds, and can still be unsafe/undefined on free-threaded builds if not otherwise protected. Never assume safe iteration under concurrent mutation. - Add explicit locking for compound operations. Wrap multi-step logic (read-check-write, counters, "get or create" patterns needing side-effect-free guarantees) in a
threading.Lock. - Do not rely on the GIL for correctness in future-proof code. Code that "works" only because the GIL serializes bytecode is fragile once run under free-threaded builds — always use explicit synchronization for compound logic.
- Verify with docs/version specifics. Confirm behavior against the target CPython version's
threadsafetydocumentation, since guarantees are an evolving part of the free-threaded build's specification.
Example 1:
Input: counts[word] = counts.get(word, 0) + 1 called concurrently from multiple threads.
Output: Unsafe on both build types — this is a read-modify-write sequence spanning two bytecode-level operations (get then __setitem__), so increments can be lost. Fix: wrap in a lock, or use collections.Counter with explicit locking, or restructure with dict.setdefault inside a lock.
Example 2:
Input: value = my_dict.get("key") executed from many threads while another thread does my_dict["key"] = new_value.
Output: Safe — both are single atomic dict operations. Each thread sees either the old or new value, never a corrupted/partial one, on both GIL and free-threaded builds.
Example 3:
Input: for k, v in shared_dict.items(): process(k, v) while another thread calls shared_dict.pop(k) concurrently.
Output: Unsafe — may raise RuntimeError (GIL build) or produce inconsistent iteration results (free-threaded build). Fix: iterate over list(shared_dict.items()) snapshot taken under a lock, or hold a lock for the full iteration.
- Prefer single built-in dict method calls over multi-statement patterns when possible.
- Use
threading.Lock(orRLockif reentrancy is needed) around any compound dict logic shared across threads. - Take a snapshot copy (
dict(d)orlist(d.items())) before iterating if concurrent mutation is possible, and take the snapshot while holding the same lock used for mutations. - When targeting free-threaded builds specifically, re-verify assumptions against
docs.python.orgthreadsafety docs for the exact CPython version — guarantees have been refined across 3.13/3.14+ releases. - Prefer
queue.Queue,concurrent.futures, or higher-level synchronization primitives over hand-rolled locking when the access pattern is producer/consumer.
- Assuming "atomic in CPython with the GIL" automatically means "safe under free-threaded CPython" — verify, don't assume equivalence.
- Using
setdefault(key, expensive_compute())and forgettingexpensive_compute()always executes eagerly, even when the key already exists — wasteful and can have unwanted side effects. - Treating
dict.update()as fully atomic when its argument is a lazily-evaluated generator/iterable that itself accesses shared mutable state — the update is atomic but the evaluation of its argument may not be. - Believing that removing the GIL (free-threaded build) makes dict-heavy code automatically thread-safe without review — it only preserves internal dict consistency, not program-level correctness of compound logic.