Why must C code never touch a Python object while the GIL is released?
answer
- Nothing raises when you break this rule
- The thread state is not attached
- Counters that are not atomic
- A container can be resized underneath you
- Copy or pin under the lock first
basics
~20 sBecause everything that makes object access safe -- non-atomic reference counts, the allocator, the type cache, the thread's exception state -- is protected by that lock. Touching an object without it corrupts memory silently rather than raising.
solid answer
~50 sIn the released window the thread has no attached interpreter state and holds no lock, so any C-API call is undefined behaviour. Three concrete failures follow. Reference counts are ordinary non-atomic increments in the default build, so a concurrent increment and decrement can be lost, freeing an object another thread still uses or leaking one nobody does. The object can be mutated or resized by another thread, so a pointer into its storage -- a list's array, a bytearray's buffer -- may dangle after a reallocation. And raising is impossible, because the exception state lives in the thread state that was just saved away. The discipline is: under the lock, extract everything you need and hold a reference to whatever owns the memory you will read; released, touch only that raw memory; re-acquire, then allocate results and raise errors.
code
python · 9 linesimport zlib
# Safe shape at the Python level: hand the native call an immutable
# snapshot, so nothing can resize the storage while it is crunching.
scratch = bytearray(1024 * 1024)
snapshot = bytes(scratch) # copy taken before the long call
scratch.extend(b"grown after the copy") # would have reallocated
print(len(zlib.compress(snapshot, 6)), len(scratch))go deeper
Remember the rule itself: while the lock is given up, native code may touch only plain memory, never Python objects, and it takes the lock back before making or raising anything.
Explain at least one concrete mechanism -- non-atomic reference counts losing an update, or a container reallocating its storage mid-call -- and describe the extract, crunch, rebuild ordering that avoids both.
Show that you know the violation corrupts silently rather than raising, so the crash appears far from the cause. Be able to lay out the safe function shape and say when copying under the lock is the cheaper engineering answer.
Own it as a review and dependency criterion: decide what the team requires of compiled code in the hot path, including free-threading readiness, and how such failures are caught before they become intermittent production crashes.
## The rule Between releasing the lock and re-acquiring it, the thread has detached its interpreter state. It has no current frame, no exception slot, and no permission to enter any code that assumes interpreter invariants. Every C-API function, and the reference-counting macros in particular, assumes those invariants. So the rule is absolute: no `Py_INCREF`, no `Py_DECREF`, no attribute access, no allocation, no raising, no logging through Python, no callback into interpreted code. The reason it is worth a senior-level interview question is that violating it does not raise. It corrupts, quietly, and the crash surfaces later in unrelated code. ## Failure one: lost reference counts In the default 3.14 build, a reference count is an ordinary integer field incremented and decremented without atomics -- which is safe precisely because the lock serializes every mutation. Two threads doing an unsynchronized increment and decrement can interleave so that one update is lost. Lose a decrement and the object leaks. Lose an increment and the count reaches zero while a live reference remains, the object is freed, and the next use reads freed memory. The symptom is a segfault or a nonsense value in a completely different part of the program, minutes later. ## Failure two: the object moves or dies underneath you Even when your code only reads, the object it is reading from is a shared, mutable thing. Another thread holding the lock can append to a list, which reallocates the backing array; can resize a bytearray, which reallocates its buffer; can rebind the last name pointing at your input, which frees it. A pointer captured before the release is then dangling. This is why the pre-release phase does two things, not one: it obtains the pointer **and** it takes a strong reference to the owner so it cannot be collected. For memory that a caller may also resize, an extension additionally needs a guarantee that no resize can occur -- either by documenting the constraint, by copying the data out under the lock, or by using an export mechanism that locks the exporter until it is released. The copy is often the correct engineering answer: cheap relative to the crunch, and it removes an entire class of lifetime bugs. ## Failure three: errors have nowhere to go The exception state lives in the thread state that was saved away. Setting an error in the released span is not possible, and there is no place to put a traceback. The pattern is therefore to record a plain C-level status -- an error code, a flag, a captured `errno` -- reach the re-acquire point on every path, and translate that status into a Python exception once the lock is back. Extensions that try to shortcut this are the ones with unbalanced macro pairs. ## The safe shape of such a function 1. **Locked**: parse arguments, validate, take a strong reference to the input owner, capture the pointer and length, copy out anything small you need. 2. **Released**: run the loop over that memory, writing into memory you own; record status in a C variable. 3. **Locked again**: drop the reference, translate status into an exception if needed, allocate and return the result object. A callback that genuinely must run Python code from the released span is the one exception, and it does not bend the rule: it acquires the lock and attaches a thread state first, with `PyGILState_Ensure`, then releases it again with `PyGILState_Release`. Inside that window the ordinary rules apply, because the lock is genuinely held. ## Free-threaded builds do not repeal it It is tempting to assume the 3.14 free-threaded build, officially supported under PEP 779, makes this moot because reference counts there are handled with a scheme designed for concurrency. It does not. A thread that has detached from the interpreter must still not touch objects: it is not participating in the interpreter's stop-the-world pauses, and it can be holding a pointer into a container another thread is concurrently mutating. Free-threading removes the process-wide lock; it does not remove the need for per-object synchronization, and the discipline in the released span is unchanged. Compiled modules must also positively declare that they are safe without the lock -- an unmarked extension causes the interpreter to re-enable the lock at import time. ## What an interviewer listens for The weak answer is `because it is not thread-safe`. The strong answer names the specific invariants: non-atomic refcounts, a mutable container whose storage may be reallocated, and an exception slot that is not attached. It then describes the extract-under-lock, crunch-released, build-under-lock shape as the way to avoid all three.
- What is the correct way to report an error detected while the lock is released?Record it as plain C state -- a status code, a flag, a saved `errno` -- and let control reach the re-acquire point on every path. Once the lock is back and the thread state is attached, translate that status into a Python exception. Never try to set an exception in the released span: there is no attached exception slot to set, and jumping out of the block to raise leaves the thread running without the lock.
- How can an extension be sure the memory it reads stays valid across the released span?By making both guarantees under the lock before releasing: hold a strong reference so the owner cannot be collected, and ensure the storage cannot be reallocated -- either because the object is immutable, because an export mechanism blocks resizing until it is released, or because the extension copied the data into memory it owns. A pointer taken from a mutable container with no such guarantee can dangle after another thread appends to it.
- Does the free-threaded build let a detached thread touch objects safely?No. Free-threading removes the process-wide lock and changes how reference counts are maintained, but a detached thread is still outside the interpreter's coordination -- it does not participate in stop-the-world pauses, and the objects it would touch may be mutated concurrently. Per-object synchronization becomes the extension author's problem rather than something the lock did for free, so the released span stays a Python-object-free zone.
It is like walking out of the library with a page number instead of the book. If nobody reshelves anything you are fine; the moment another reader re-sorts the shelf, your page number points at somebody else's text -- and nothing warns you.
saying these in an interview costs you the question
- Says it is fine as long as the code only reads the object
- Believes an illegal access raises an exception you can catch
- Thinks holding a pointer is enough without a reference
- Claims the free-threaded build removes the restriction
- Wants to raise a Python exception from the released span
- Assumes reference counting is atomic in the default build