skip to content

Which CPython list and dict operations are effectively atomic across threads?

level: middleimportance: must knowfreq 55%

answer

  1. One C call, no bytecode inside
  2. Atomicity does not compose
  3. Append is fine, plus-equals is not
  4. Python `__hash__` reopens the window
  5. An implementation detail, not a guarantee

basics

~20 s

Single C-level calls that never run Python bytecode are effectively atomic: list.append, list.pop, d[k] = v, x = d[k], d.setdefault, d.update. Anything built from two of them — d[k] += 1, or a membership test followed by an assignment — is not.

solid answer

~40 s

An operation is *effectively atomic* in CPython when it completes inside one C-level call that does not execute Python bytecode, so no thread switch is observable in the middle. That covers `shared_list.append(x)`, `shared_list.pop()`, `shared_list.extend(other)`, `x = shared_dict[k]`, `shared_dict[k] = v`, `shared_dict.setdefault(k, v)` and `shared_dict.update(other)`. It does **not** cover compositions: `shared_dict[k] += 1` is a read-modify-write, and `if k not in shared_dict: shared_dict[k] = v` is check-then-act. Two caveats matter. First, a key with a Python-level `__hash__` or `__eq__` runs bytecode *inside* the dict operation, so a switch can happen there. Second, this is a CPython implementation detail, not a language guarantee — rely on it for a coarse append, not as the foundation of a design.

code

python · 25 lines
python
import threading

results = []
seen = {}
lock = threading.Lock()

def worker(item):
    results.append(item)          # effectively atomic: no lock needed

    # racy: the membership test and the assignment are two operations
    if item not in seen:
        seen[item] = 0

    # correct: one atomic call, or a lock around the whole invariant
    seen.setdefault(item, 0)
    with lock:
        seen[item] += 1

threads = [threading.Thread(target=worker, args=(i % 3,)) for i in range(30)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(len(results), sum(seen.values()))

go deeper

for a junior

Recall the short list — list.append, list.pop, plain item get and set on a dict — and the headline caveat that combining two of them is no longer safe. You are not expected to reason about __hash__ yet.

for a middle

Explain why these operations are atomic at all: one C-level call, no Python bytecode in the middle, so no thread switch is observable. Then show the composition trap with a concrete two-statement counter update.

for a senior

Demonstrate judgement about when leaning on it is legitimate — lock-free fan-in appends, snapshot-then-iterate — and when it is a fragile bet that the next edit will break. Name the __hash__/__eq__/__del__ escape hatches.

for a principal

Own the guidance you give the codebase: effective atomicity is a local optimisation, not an architecture. Push teams toward confinement and message passing so correctness does not depend on a reader knowing which C calls happen to be indivisible.

### What "effectively atomic" means CPython's built-in containers are implemented in C. When you call `shared_list.append(item)`, the interpreter enters a C function, mutates the list, and returns — all while holding the GIL, and without ever executing a Python bytecode that would give the eval loop a chance to hand the GIL to another thread. From the point of view of other Python threads the operation is indivisible: they see the list before the append or after it, never halfway through. That is what the phrase *effectively atomic* means. It is a property of the implementation, not a promise made by the language reference. The practical list, for the operations people actually reach for: **Effectively atomic** - `shared_list.append(x)`, `shared_list.pop()`, `shared_list.pop(i)`, `shared_list.extend(other)` - `x = shared_list[i]`, `shared_list[i] = x` - `x = shared_dict[k]`, `shared_dict[k] = v`, `del shared_dict[k]` - `shared_dict.setdefault(k, default)`, `shared_dict.update(other)`, `shared_dict.pop(k, default)` - `snapshot = list(shared_dict)` or `list(shared_list)` — one C-level copy **Not atomic** - `shared_dict[k] += 1` and `shared_list[i] += 1` — a read, an add and a write - `if k not in shared_dict: shared_dict[k] = v` — check-then-act - `if shared_list: item = shared_list.pop()` — check-then-act; the list can be emptied between the test and the pop, and the pop then raises `IndexError` - `shared_list.append(a); shared_list.append(b)` — each append is atomic, the *pair* is not, so another thread can interleave between them - iterating `for k in shared_dict:` while another thread inserts — raises `RuntimeError: dictionary changed size during iteration` ### The trap: atomic parts, racy whole The most common failure is not misidentifying an atomic operation, it is assuming atomicity composes. It does not. Two atomic operations performed in sequence are just two operations, and every gap between them is a window another thread can step into. The counter idiom `shared_dict[k] = shared_dict.get(k, 0) + 1` is built from two perfectly atomic operations and is still a lost-update race. If your invariant spans more than one call, you need a lock around the whole sequence, or a design where only one thread touches the structure. ### The second trap: your code inside their C code Effective atomicity holds only while the C function stays in C. Several things drag Python bytecode back into the middle of a "single" operation: - A key whose `__hash__` or `__eq__` is written in Python — the dict calls them during lookup and insertion, and while they run, a thread switch is possible. - A `__del__` method, or a weakref callback, on an object whose last reference disappears during the operation. Deleting a dict entry can drop a refcount to zero and run arbitrary Python. - A comparison function: `shared_list.sort(key=lambda ...)` runs Python for every comparison. So `d[user] = record` is atomic when `user` is a `str` or an `int`, and is not obviously atomic when `user` is an instance of a class with a hand-written `__eq__`. ### How to use this knowledge The honest interview answer is: know the list, and then mostly do not depend on it. It buys you two legitimate things. 1. **Cheap fan-in.** Many worker threads appending results to one list, or writing distinct keys into one dict, need no lock. This is a real and common pattern and it is correct. 2. **Cheap snapshots.** `for k in list(shared_dict):` takes an atomic copy of the keys and iterates the copy, which is the standard way to iterate a structure another thread is mutating. For everything else, prefer designs that do not need the reasoning at all: a `threading.Lock` held across the whole invariant, a `queue.Queue` so one owner thread performs all the mutations, or confinement — each thread mutates its own structure and the results are merged once, after `join()`. Those designs stay correct when a colleague later inserts a second statement into what used to be a single atomic call. ### Why this is not a language guarantee Nothing in the language reference says `list.append` is atomic. It is a consequence of how CPython implements lists plus how the GIL is released, and code that depends on it is depending on the interpreter. CPython has kept these properties stable through 3.14, and the free-threaded build preserves per-object atomicity for the same built-in operations, but the composite races above are unaffected either way — removing the GIL does not remove a check-then-act bug. Treat "this operation happens to be atomic" as a performance argument for skipping a lock in a hot, simple path, never as the correctness story you write down in a design document.

  • Why does iterating a shared dict directly risk a `RuntimeError`, and what is the idiom that avoids it?
    A dict iterator checks the dict's version on each step and raises `RuntimeError: dictionary changed size during iteration` if another thread inserted or deleted meanwhile. The idiom is to take an atomic snapshot first — `for key in list(shared_dict):` — which copies the keys in one C-level call and then iterates the copy. Values fetched afterwards may be stale, which is usually acceptable; if it is not, hold a lock instead.
  • What does `dict.setdefault` buy over `if k not in d: d[k] = v`?
    `setdefault` performs the lookup and the conditional insert inside one C-level call, so no other thread can slip between the test and the write. The two-statement form is a check-then-act race: two threads can both find the key missing and both insert, and whichever value is inserted second wins. `setdefault` also evaluates its default argument eagerly, so pass a cheap value, not an expensive call.
  • You see `shared_dict[k] = shared_dict.get(k, 0) + 1` in a code review. What is wrong with it?
    Both `get` and item assignment are atomic on their own, but the pair is a read-modify-write with a real gap between them, so concurrent updates to the same key lose counts. Fix it by holding a lock across both statements, by giving each thread its own dict and merging after `join()`, or by routing all updates through one owner thread fed by a `queue.Queue`.

Each atomic operation is a single stamp of a rubber stamp: nobody sees it half-pressed. Stamping twice to record one transaction is still two chances for someone else to grab the form in between.

saying these in an interview costs you the question

  • Assumes two atomic operations in sequence are atomic together
  • Calls `d[k] += 1` atomic because item assignment is
  • Treats effective atomicity as a language guarantee
  • Forgets that a Python `__hash__` or `__eq__` runs mid-operation
  • Iterates a shared dict without snapshotting the keys
  • Says removing the GIL would fix check-then-act code

context