TRT
Sign in
easyFoundationsconcept

What is inside a CPython PyObject in memory, how does `b = a` work under the hood, and why does `1000 is 1000` behave differently in a script vs the REPL?

Explain the 16-byte PyObject header (ob_refcnt, ob_type), small-integer interning [-5, 256], compiler constant folding per code object, and PEP 683 immortal objects.

TRT practice prompt — not a verified question from a named employer.

The Runtime Theory Team2 min read

A strong answer

In CPython, there are no unboxed primitive variables. Every value—even an integer—is a heap-allocated C struct (PyObject or PyVarObject) containing at least a 64-bit reference count (ob_refcnt, 8 bytes), a 64-bit type pointer (ob_type, 8 bytes), and the payload (ob_digit, 12+ bytes for int = 28 bytes per integer). A Python variable name is merely a dictionary or fast-local slot holding an 8-byte pointer (PyObject*) to that heap struct. Assignment b = a never copies the object—it copies the 8-byte pointer and increments ob_refcnt += 1 (Py_INCREF). While == calls __eq__ to compare payload values, is compares the raw memory addresses (id(a) == id(b)). CPython pre-allocates a singleton array of small integers [-5, 256], which is why 5 is 5 is always True while freshly allocated 1000 objects have distinct addresses.

At every bytecode instruction boundary, an object's ob_refcnt equals the exact number of live PyObject* references pointing to it from stack frames, containers, and runtime singletons; the moment ob_refcnt drops to 0, CPython immediately invokes its type's deallocator (tp_dealloc). Because every assignment (STORE_FAST), container insertion, and argument pass executes Py_INCREF and every scope exit, del, or reassignment executes Py_DECREF, deterministic reference counting reclaims non-cyclic objects at the exact instruction their last reference disappears.

A single CPython int costs 28 bytes on 64-bit systems (vs 4 or 8 bytes in C/Rust), and every assignment or function pass touches the object header in memory to increment and decrement ob_refcnt. In production systems (CPython 3.12+ (including Immortal Objects PEP 683 for None, True, False, and small ints so multi-core workers don't bounce cache lines on ob_refcnt), PyTorch tensor wrappers, and NumPy array headers all build on PyObject reference semantics.), engineers monitor these exact trade-offs to keep p99 latency predictable and prevent resource exhaustion under peak load.

What the interviewer is listening for

  • Traces how CPython name binding and PyObject reference counting operates in memory step by step rather than giving a vague textbook definition.
  • States the core correctness invariant and explains why edge cases do not violate it.
  • Quantifies time, space, and hardware costs (Name Assignment (b = a), 64-bit CPython int (PyLongObject)) with concrete numbers.
  • Identifies real-world failure modes (Writing if status is 'OK': instead of if status == 'OK':) and explains how production systems mitigate them.

Common mistakes

  • Treating CPython name binding and PyObject reference counting as a black box without explaining the underlying memory or state transitions.
  • Ignoring worst-case tail latency or memory overhead (Creating a list of 50 million float objects in pure Python).
  • Forgetting boundary conditions and error recovery (Holding a reference to a giant DataFrame in an outer scope variable).

This answer walks

Practice follow-ups

  1. 01Why did CPython 3.12 introduce 'Immortal Objects' (PEP 683) with a fixed bit pattern in `ob_refcnt` for `None`, `True`, `False`, and small ints?
  2. 02If reference counting frees objects immediately when `ob_refcnt == 0`, why does Python still need a cyclic garbage collector (`gc` module)?

Not started

Sign in to save your learning progress.

Sign in to save