{}The Interview
Handbook

Tracks / Python

How CPython actually runs your code

staffDeep dive 9 sections · 10 min read cpythoninternalsmemorygilbytecode

Questions in this set 9
  1. 01What is a Python object, really?
  2. 02How does memory management work below refcounting?
  3. 03What does the GIL actually do, at the level of the implementation?
  4. 04What does the interpreter loop look like?
  5. 05What changed in 3.11-3.13 that made Python faster?
  6. 06Why is a dict so fast, and what changed in 3.7?
  7. 07How do descriptors, the MRO and super() fit together?
  8. 08What actually happens on import?
  9. 09Where does the time actually go in a slow Python function?

Python's reputation for being slow, and its concurrency model, and half of its surprising behaviours, all come from a handful of implementation decisions. Knowing them turns "Python is slow" into "attribute access is a dict lookup plus a descriptor check, which is why __slots__ and local variables matter" — and that is the difference between a mid and a senior answer.

01

What is a Python object, really?

Every value is a heap-allocated C struct beginning with a common header:

c
typedef struct _object {
    Py_ssize_t ob_refcnt;        // reference count
    PyTypeObject *ob_type;       // pointer to the type object
} PyObject;

That is 16 bytes before any actual data. An int adds a sign/size field and a variable number of 30-bit digits, so a small int is 28 bytes. A one-character str is 50+. An empty dict is 64; an empty list is 56 plus a separately-allocated array of pointers.

Everything follows from this:

  • A list of a million integers is a million pointers (8 MB) plus a million 28-byte objects (28 MB) — about 36 MB, versus 8 MB for a NumPy int64 array or 4 MB for array('i'). This is the memory half of "why NumPy is faster"; the other half is that NumPy loops in C over contiguous memory with no per-element object dispatch.
  • a is b for small integers is an artefact of interning: CPython preallocates int objects from -5 to 256, so they are shared. Same for short strings that look like identifiers. Never rely on it.
  • Everything is a reference. Assignment binds a name to an object; it never copies. This is the mechanism behind mutable default arguments, aliasing bugs, and copy vs deepcopy.
  • Attribute access is not a field offset. obj.x consults the type's MRO for a data descriptor, then the instance __dict__, then non-data descriptors, then __getattr__. That is several dict lookups where a compiled language does one pointer arithmetic operation.
02

How does memory management work below refcounting?

Three layers, and people usually only know the top one.

Reference counting frees an object the instant its count reaches zero — deterministic, immediate, no pause. The costs: every assignment, function call and container operation mutates a counter (which is also why the GIL exists — those counters are not atomic), and it cannot free cycles.

The cycle collector handles what refcounting cannot. It is generational: new objects are in generation 0, survivors are promoted to 1 then 2, and older generations are scanned less often. It only tracks container objects (things that can reference others), so a large bytes object is never scanned. Collection walks the tracked set, subtracts internal references, and anything with a remaining count of zero is unreachable and freed.

The allocator is where the interesting production behaviour lives:

text
object > 512 bytes  ->  malloc()
object <= 512 bytes ->  pymalloc:  pools (4 KB) grouped into arenas (1 MB, or 256 KB pre-3.10)

pymalloc exists because CPython allocates constantly and malloc is too slow for that. Its consequence is the one that shows up in incidents: memory is returned to the OS only when an entire arena becomes free. One surviving object pins a whole arena, so a process that briefly held a million small objects can keep a high RSS forever even though Python's heap is nearly empty. That is why tracemalloc can show a flat heap while RSS climbs, and why long-lived Python workers are routinely recycled (--max-requests) rather than debugged.

03

What does the GIL actually do, at the level of the implementation?

The GIL is one mutex per interpreter, held while executing bytecode and while touching any object's refcount. Without it, every Py_INCREF would need an atomic operation, which measurably slows single-threaded code — that is the trade CPython made.

The mechanics that matter:

  • A thread holds the GIL and releases it either voluntarily (before a blocking syscall — file and socket I/O, time.sleep, and inside C extensions that call Py_BEGIN_ALLOW_THREADS) or when the switch interval (sys.setswitchinterval(), 5 ms by default) expires and another thread has requested it.
  • Requesting is a "drop request" flag plus a condition variable, not a fair queue. Before 3.2's rewrite this caused the notorious convoy effect, where a CPU-bound thread repeatedly reacquired the GIL and starved an I/O-bound thread; the modern implementation is much better but there is still no priority.
  • The GIL is per-interpreter, and since 3.12 (PEP 684) sub-interpreters can each have their own — which is the foundation for real parallelism without processes, though the API is still maturing.
  • 3.13 ships an experimental free-threaded build (PEP 703, --disable-gil). It uses biased reference counting and deferred refcounting, requires C extensions to be rebuilt and declared compatible, and costs single-threaded performance. It is the future, not the present; planning production around it today is premature, and saying so is the correct answer.

The practical inference chain to state in an interview: refcounts are non-atomic → a global lock protects them → only one thread executes bytecode → threads do not help CPU-bound Python → but the lock is released around I/O, so threads do help I/O-bound Python → and C extensions that release it (NumPy, hashlib, compression, most DB drivers) genuinely parallelise.

04

What does the interpreter loop look like?

Source is compiled to bytecode — instructions for a stack machine — cached in __pycache__. The evaluation loop pops instructions and executes them against a value stack.

python
import dis
def f(a, b):
    return a + b * 2
dis.dis(f)
#   LOAD_FAST     a          <- locals are an ARRAY index, not a dict lookup
#   LOAD_FAST     b
#   LOAD_CONST    2
#   BINARY_OP     5 (*)
#   BINARY_OP     0 (+)
#   RETURN_VALUE

Two structural facts with practical consequences:

Locals are fast, globals are not. Function locals are resolved at compile time to slots in an array (LOAD_FAST). Globals and builtins are dict lookups (LOAD_GLOBAL) through the module dict and then the builtins dict. This is the real reason for the old micro-optimisation of binding len to a local inside a hot loop — and why a function is faster than the same code at module level.

Every frame is an object. A call builds a frame with its own value stack and locals array. That is why Python function calls are relatively expensive, why recursion hits sys.setrecursionlimit() (default 1000) rather than a real stack limit, and why generators can suspend — a generator is a frame whose state persists between next() calls. It is also why tracebacks can retain a lot of memory: a frame holds references to every local, so one retained exception can pin megabytes.

05

What changed in 3.11-3.13 that made Python faster?

The Faster CPython work, and it is worth being specific because "3.11 is faster" is a weak answer:

  • Adaptive specialising interpreter (PEP 659). Bytecode instructions rewrite themselves at runtime based on observed types. BINARY_OP on two ints becomes BINARY_OP_ADD_INT, skipping generic dispatch. LOAD_ATTR specialises on the observed class layout, becoming close to a direct offset. This is inline caching — a JIT technique without a JIT — and it is why type-stable code got much faster while polymorphic code did not.
  • Cheaper frames. Frame objects are lazily materialised, so calls got substantially cheaper. Python-to-Python calls are also inlined into the interpreter loop rather than recursing into C.
  • Zero-cost exceptions. try blocks now cost nothing when no exception is raised (the handler locations live in a side table), so defensive try/except in hot paths is no longer a performance argument.
  • Much better error messages — fine-grained locations in tracebacks pointing at the exact sub-expression.
  • 3.12: per-interpreter GIL groundwork, a new type system for extensions, sys.monitoring for low-overhead tooling.
  • 3.13: an experimental JIT (copy-and-patch), free-threaded build, an improved REPL.

The honest summary for an interview: 3.11 gave roughly 25% on typical workloads for free, the gains come from specialisation rather than compilation, and none of it changes the fundamentals — attribute access, allocation and dynamic dispatch are still the costs, and the answer to a genuinely CPU-bound problem is still NumPy, Rust, C, or another process.

06

Why is a dict so fast, and what changed in 3.7?

CPython dicts are open-addressed hash tables with a compact layout (since 3.6, guaranteed ordered since 3.7): a sparse array of indices pointing into a dense, insertion-ordered array of entries.

text
indices:  [ -1, 0, -1, -1, 1, -1, 2, -1 ]      # sparse, small ints
entries:  [ (hash, key, value), (hash, key, value), (hash, key, value) ]   # dense, ordered

The consequences: dicts are ~20-25% smaller than the old design, iteration is over contiguous memory, and insertion order is preserved as a language guarantee rather than an implementation detail. Collisions are handled by open addressing with a probing sequence that mixes in the higher hash bits, and the table resizes (to two-thirds capacity) by rebuilding.

Two things worth knowing beyond that: string hashing is randomised per process (PYTHONHASHSEED) to prevent hash-collision denial of service, so set iteration order is not stable across runs; and instances of a class share a key-sharing dict (PEP 412), so per-instance __dict__ overhead is much lower than the naive 64 bytes — which narrows, but does not eliminate, the memory case for __slots__.

07

How do descriptors, the MRO and super() fit together?

Attribute lookup on an instance is roughly:

  1. Walk type(obj).__mro__ looking for the name.
  2. If found and it is a data descriptor (defines __set__ or __delete__), call its __get__ and return. Data descriptors win over the instance dict — this is why @property cannot be shadowed by an instance attribute.
  3. Otherwise check obj.__dict__.
  4. Otherwise, if the class attribute is a non-data descriptor (a plain function, staticmethod, classmethod), call its __get__.
  5. Otherwise return the class attribute, or call __getattr__, or raise AttributeError.

Step 4 is how methods work at all: a function is a non-data descriptor whose __get__ returns a bound method with self already attached. classmethod and staticmethod are descriptors that bind differently. property is a data descriptor. There is no special-casing anywhere — one protocol explains all of it.

The MRO is computed by C3 linearisation, and super() walks the MRO of type(self) starting after the current class — which is why super() in a diamond can dispatch to a sibling rather than a parent, and why cooperative multiple inheritance requires every class to call super().

08

What actually happens on import?

  1. Check sys.modules — if present, return it. Imports are cached, and a module's top-level code runs exactly once per process.
  2. Otherwise find it via sys.meta_path finders, which consult sys.path.
  3. Load: read the .pyc if its source hash/mtime matches, else compile and write one.
  4. Create the module object and insert it into sys.modules before executing its body. This is deliberate, and it is what makes circular imports partially work: the second import gets a half-initialised module, which is why you see ImportError: cannot import name 'X' (most likely due to a circular import) — the name is not defined yet.
  5. Execute the module body.

The practical inferences: module-level code is import-time cost (a slow CLI is usually import time, measurable with python -X importtime); circular imports are fixed by moving the import inside a function, restructuring, or TYPE_CHECKING guards for type-only imports; and module-level mutable state is process-global, which is why it is the wrong place for request-scoped data.

09

Where does the time actually go in a slow Python function?

In rough order of what profiles show:

  1. I/O you did not notice — an N+1 query, a per-item HTTP call. Almost always the real answer, and it dwarfs everything below.
  2. Allocation and garbage — building millions of intermediate objects. Generators and in-place operations help; gc.disable() during a large batch build is a legitimate trick.
  3. Attribute and global lookups in hot loops — each is a dict lookup plus descriptor protocol.
  4. Function call overhead — real, though much cheaper since 3.11. Inlining a tiny helper into a hot loop can measurably help.
  5. Boxing — every arithmetic operation allocates a new int/float object. This is the ceiling that NumPy and Rust extensions exist to break.

Which produces the correct escalation: fix the algorithm and the I/O → use built-ins and comprehensions (they run in C) → functools.cache for pure repeated work → vectorise with NumPy/Polars → move the hot kernel to Cython, Rust (PyO3) or C → and only then consider a different runtime. Reaching for the last step first is the classic mistake, and knowing the ordering because you know where the time goes is the point of all of this.