Python Internals
CPython Architecture Overview
Section titled “CPython Architecture Overview”CPython is the reference implementation of Python, written in C. It is the most widely used Implementation and the one most people mean when they say “Python.” Understanding its architecture Is essential for performance tuning, debugging segfaults in C extensions, and reasoning about Python”s memory and concurrency behavior.
The execution pipeline in CPython is:
- Lexer: Converts source code into tokens. Hand-written, located in
Parser/tokenizer.c. - Parser: Converts tokens into a Concrete Syntax Tree (CST), then into an Abstract Syntax Tree (AST). The parser is generated by a PEG (Parsing Expression Grammar) generator as of Python 3.9 (replacing the older LL(1) parser).
- Compiler: Walks the AST and produces bytecode. Located in
Python/compile.c. The output is acodeobject containing bytecode instructions, constants, variable names, and metadata. - Eval loop: Executes bytecode instructions one at a time in a stack-based virtual machine. Located in
Python/ceval.c. This is the heart of CPython and where the GIL lives.
Source (.py) | vLexer (tokenizer.c) --> Tokens | vParser (pegen/) --> AST (asdl_c.py) | vCompiler (compile.c) --> Code Object (bytecode) | vEval Loop (ceval.c) --> ResultsOther Implementations
Section titled “Other Implementations”- PyPy: A Python interpreter written in Python (specifically, in a subset called RPython). Uses a tracing JIT compiler to generate machine code at runtime. Can be 4-5x faster than CPython for long-running programs. Trade-off: slower startup time, incomplete C extension compatibility.
- GraalPy: Runs on the GraalVM, a polyglot VM with a JIT compiler. Good C extension compatibility via GraalVM’s Sulong runtime. Performance is competitive with PyPy for some workloads.
- MicroPython / CircuitPython: Minimal implementations targeting microcontrollers with limited RAM (as little as 256KB). Strip out most of the standard library and use smaller internal data structures.
All implementations must conform to the Python Language Reference, but they differ in performance Characteristics, memory layout, and C extension compatibility. Code that relies on CPython-specific Behavior (reference counting timing, integer caching ranges, __dict__ implementation) may behave Differently on other implementations.
The Object Model
Section titled “The Object Model”Every value in Python is an object. Not just class instances — integers, strings, functions, Modules, stack frames, and even None are objects. There are no “primitive types” in the C or Java Sense. This uniformity simplifies the language semantics but has a cost in memory and performance.
PyObject
Section titled “PyObject”Every Python object begins with a PyObject header, defined in C as:
typedef struct _object { Py_ssize_t ob_refcnt; // Reference count PyTypeObject *ob_type; // Pointer to the type object} PyObject;On a 64-bit system, this header is 16 bytes: 8 bytes for the reference count and 8 bytes for the Type pointer. Every object, no matter how small, carries at least this overhead. A Python int Holding the value 0 consumes at minimum 28 bytes in CPython (16-byte header + 4-byte digit array + Alignment padding).
What This Means for Performance
Section titled “What This Means for Performance”The ob_refcnt field is incremented and decremented on every assignment, function argument pass, Container insertion, and scope exit. Each increment/decrement is a thread-safe atomic operation (in Free-threaded builds) or a simple integer increment (in GIL builds). This per-operation cost is why Python is slower than C for tight loops over primitive values — a C int addition is a single Machine instruction, while a Python int addition involves:
- Load the left operand’s
PyObjectpointer. - Check
ob_typeto confirm it is anint. - Load the right operand’s
PyObjectpointer. - Check
ob_typeto confirm it is anint. - Extract the digit arrays from both operands.
- Perform the arithmetic.
- Allocate a new
PyObjectfor the result. - Set the result’s
ob_typeto&PyLong_Type. - Set the result’s
ob_refcntto 1. - Decrement the reference count of both operands.
The ob_type pointer enables dynamic dispatch. When Python evaluates a + bIt follows a.ob_type to the type object, then looks up the tp_as_number->nb_add slot to find the addition Function. This is a two-level indirection per operation. The tp_slots mechanism is how Python’s Data model (dunder methods) maps to C function pointers.
PyTypeObject
Section titled “PyTypeObject”The type object is itself a PyObject (with its own reference count and type pointer — the type of A type is typeAnd the type of type is type). It additionally contains a large struct with Function pointers for every operation the object supports: tp_repr``tp_hash``tp_call tp_iternext``tp_as_sequence``tp_as_mappingAnd dozens more. These slots are the C-level Equivalent of dunder methods.
Reference Counting
Section titled “Reference Counting”Reference counting is CPython’s primary memory management mechanism. Every object has a reference Count (ob_refcnt) that tracks how many references to the object exist. When the count drops to Zero, the object is immediately deallocated.
When References Are Incremented
Section titled “When References Are Incremented”- Assignment:
a = objincrementsobj.ob_refcnt. - Argument passing:
f(obj)incrementsobj.ob_refcntfor the duration of the call. - Container insertion:
lst.append(obj)ord[key] = objincrements the count. - Returning a reference:
return objin a function creates a new reference in the caller’s scope.
When References Are Decremented
Section titled “When References Are Decremented”- Name rebinding:
a = new_objdecrements the oldobj’s count. - Scope exit: when a function returns, all local variables have their reference counts decremented.
- Container removal:
lst.remove(obj)ordel d[key]decrements the count. delstatement:del aremoves the name from the current scope and decrements the count.
sys.getrefcount()
Section titled “sys.getrefcount()”import sys
a = [1, 2, 3]print(sys.getrefcount(a)) # 2: one for 'a', one for the temporary argument to getrefcountsys.getrefcount() returns the reference count plus one, because the call to getrefcount itself Creates a temporary reference. This makes it easy to misinterpret the output. If you see a count of 3, the actual count from your code’s perspective is 2.
The Cost of Reference Counting
Section titled “The Cost of Reference Counting”Every reference count operation is a C-level increment or decrement of a Py_ssize_t. In a GIL-enabled build, this is a non-atomic operation protected by the GIL. In a free-threaded build (PEP 703, Python 3.13+), this becomes an atomic increment/decrement, which is significantly more Expensive due to cache line contention between CPU cores.
The cumulative cost of reference counting is substantial. In a tight loop that creates and discards Temporary objects (e.g., string concatenation, numeric computation), a significant fraction of CPU Time is spent on reference count manipulation rather than actual computation. This is one reason Python loops are slow relative to C loops.
Circular References
Section titled “Circular References”Reference counting cannot reclaim objects that reference each other in a cycle:
a = []b = []a.append(b)b.append(a)del adel b## Both lists have refcount 1 (each other), but are unreachable from the stackWithout the cycle detector, these objects would leak permanently. CPython includes a cyclic garbage Collector (discussed below) that periodically scans for and breaks these cycles.
Garbage Collection
Section titled “Garbage Collection”CPython’s garbage collector operates alongside reference counting. It is specifically designed to Handle the circular reference problem that reference counting alone cannot solve.
Generational GC
Section titled “Generational GC”CPython uses a generational garbage collector with three generations:
- Generation 0: Newly created objects. Collected most frequently.
- Generation 1: Objects that survived one or more generation-0 collections.
- Generation 2: Objects that survived multiple collections. Collected least frequently.
The hypothesis (and empirical observation) is that most objects die young. Newly created temporary Objects (function locals, intermediate results) are collected quickly and cheaply in generation 0. Only objects that prove their longevity (by surviving collections) are promoted to older Generations, which are scanned less often.
The gc module provides direct control over the garbage collector:
import gc
gc.collect() # Force a full collection of all generationsgc.get_count() # Current collection counts for each generationgc.get_threshold() # Thresholds: (700, 10, 10) by defaultgc.set_threshold(1000, 15, 15) # Adjust thresholdsgc.get_objects() # All objects tracked by the GC (expensive)gc.get_referrers(obj) # Objects that reference 'obj'gc.get_referents(obj) # Objects referenced by 'obj'The thresholds control when each generation is collected. When the number of allocations minus Deallocations exceeds the generation-0 threshold (default 700), generation 0 is collected. If Generation 0 has been collected the threshold number of times (default 10), generation 1 is also Collected. The same logic applies for generation 2.
What the GC Tracks
Section titled “What the GC Tracks”The GC only tracks container objects that can participate in cycles: lists, dicts, sets, instances Of user-defined classes, tuples, and similar. It does not track immutable atomic objects (integers, Strings, floats) because they cannot hold references to other objects. Note that tuples are tracked Because they can contain references to mutable objects, even though the tuple itself is immutable.
__del__ and the Finalizer Problem
Section titled “__del__ and the Finalizer Problem”Objects with __del__ methods complicate garbage collection. The GC cannot safely break a cycle That includes objects with __del__Because breaking the cycle requires deallocating at least one Object, which triggers __del__Which might access other objects in the cycle that have already Been deallocated.
In CPython 3.4+, the GC handles this by placing such objects in a list (gc.garbage) and reporting Them as unreachable but uncollectible. The programmer must explicitly break the cycle.
import gc
class Node: def __init__(self, name): self.name = name self.parent = None self.children = []
def __del__(self): print(f"Deleting {self.name}")
root = Node("root")child = Node("child")root.children.append(child)child.parent = root
del rootdel child
gc.collect()## The nodes are in gc.garbage because __del__ prevents cycle collectionAvoid __del__ whenever possible. Use context managers (with blocks) for resource cleanup, or Use weakref.ref callbacks, or use the atexit module. __del__ is fundamentally at odds with the Garbage collector’s ability to reclaim cyclic garbage.
Weak References
Section titled “Weak References”The weakref module provides a way to reference an object without incrementing its reference count. A weak reference does not prevent the object from being garbage collected. When the referent is Deallocated, the weak reference returns None.
import weakref
class CacheEntry: def __init__(self, key, value): self.key = key self.value = value
entry = CacheEntry("user:42", {"name": "Alice"})ref = weakref.ref(entry)
print(ref()) # <CacheEntry object at 0x...> (the referent is alive)
del entryimport gcgc.collect()print(ref()) # None (the referent was collected)weakref.WeakKeyDictionary and weakref.WeakValueDictionary are dictionary variants that use weak References for keys or values, allowing the entries to be automatically removed when the referenced Objects are garbage collected.
import weakref
cache = weakref.WeakValueDictionary()
def get_user(user_id): if user_id not in cache: cache[user_id] = fetch_from_db(user_id) return cache[user_id]This is the correct pattern for caches where you want entries to be evicted automatically when no Other code holds a reference to the cached object.
gc.disable() and When to Use It
Section titled “gc.disable() and When to Use It”gc.disable() disables the automatic garbage collector. This can improve performance in short-lived Programs or in sections of code where you know no cycles will be created. In long-running server Processes, disabling the GC is generally a bad idea because cycles will accumulate and cause memory Leaks.
A common pattern in latency-sensitive code:
import gc
def latency_critical_section(): gc.disable() try: perform_work() finally: gc.enable() gc.collect()This defers GC to the end of the critical section. The finally block ensures the GC is re-enabled Even if perform_work() raises an exception.
Bytecode and the dis Module
Section titled “Bytecode and the dis Module”The Compilation Pipeline
Section titled “The Compilation Pipeline”When CPython executes a .py file, it compiles the source to bytecode and caches the result in a .pyc file (in the __pycache__ directory). On subsequent imports, CPython loads the cached Bytecode directly, skipping the lexing and parsing stages. The .pyc file contains a marshalled Code object with a magic number (for version compatibility), a timestamp, and the bytecode itself.
The compilation stages are:
- Source to AST: The PEG parser converts source code into an AST. You can inspect this with
ast.parse(). - AST to bytecode: The compiler walks the AST and emits bytecode instructions. Each function, class, and module gets its own
codeobject. - Bytecode is stored in a
codeobject alongside metadata: constant table, variable names, free variable names, cell variable names, filename, line number table, and more.
dis.dis() and dis.Bytecode
Section titled “dis.dis() and dis.Bytecode”import dis
def add(a, b): return a + b
dis.dis(add)# 2 0 LOAD_FAST 0 (a)# 2 LOAD_FAST 1 (b)# 4 BINARY_ADD 0 (a)# 6 RETURN_VALUE 1 (b)The dis.Bytecode object provides a more programmable interface:
import dis
def example(x): if x > 0: return x * 2 return -x
bc = dis.Bytecode(example)for instr in bc: print(f"{instr.offset:4d} {instr.opname:20s} {instr.argrepr}")# 0 LOAD_FAST x# 2 LOAD_CONST 0# 4 COMPARE_OP ># 8 POP_JUMP_IF_FALSE 14# 10 LOAD_FAST x# 12 LOAD_CONST 1# 14 BINARY_MULTIPLY# 16 RETURN_VALUE# 18 LOAD_FAST x# 20 UNARY_NEGATIVE# 22 RETURN_VALUEThe Stack-Based VM
Section titled “The Stack-Based VM”CPython’s eval loop is a stack-based virtual machine. Most bytecode instructions either push a value Onto the stack, pop a value from the stack, or both. Binary operations like BINARY_ADD pop two Values, compute the result, and push it. LOAD_FAST pushes a local variable onto the stack. RETURN_VALUE pops the top of stack and returns it.
The stack is an array of PyObject* pointers. Stack operations are array index manipulations — Push is stack[++sp] = valuePop is value = stack[sp--]. This is extremely fast in C but still Adds overhead compared to register-based machines (like the JVM or LuaJIT) because every instruction Involves memory access to the stack array.
Code Object Attributes
Section titled “Code Object Attributes”def greet(name, greeting="Hello"): message = f"{greeting}, {name}!" return message
code = greet.__code__
print(code.co_varnames) # ('name', 'greeting', 'message')print(code.co_argcount) # 2 (positional args: name, greeting)print(code.co_kwonlyargcount) # 0print(code.co_consts) # (None, 'Hello', ', ', '!')print(code.co_names) # ()print(code.co_filename) # '<string>'print(code.co_firstlineno) # 1print(code.co_nlocals) # 3 (name, greeting, message)Key attributes:
co_varnames: Names of local variables (including parameters).co_argcount: Number of positional arguments (excluding*args).co_consts: Tuple of constants used in the function (literal values loaded byLOAD_CONST).co_names: Names of global variables and attributes (used byLOAD_GLOBAL``LOAD_ATTR).co_freevars: Names of free variables (variables from enclosing scopes, used by closures).co_cellvars: Names of cell variables (local variables referenced by nested functions).
Common Bytecode Instructions
Section titled “Common Bytecode Instructions”| Instruction | Stack Effect | Description |
|---|---|---|
LOAD_FAST name | push local | Load local variable onto stack |
STORE_FAST name | pop to local | Store top of stack into local variable |
LOAD_CONST const | push const | Load a constant onto stack |
LOAD_GLOBAL name | push global | Load a global variable |
LOAD_ATTR name | pop obj, push attr | Load attribute from object |
STORE_ATTR name | pop value, pop obj | Store attribute on object |
BINARY_ADD | pop b, pop a, push a+b | Addition |
BINARY_MULTIPLY | pop b, pop a, push a*b | Multiplication |
COMPARE_OP op | pop b, pop a, push result | Comparison (>``<``==Etc.) |
POP_JUMP_IF_FALSE target | pop, jump if false | Conditional jump |
CALL_FUNCTION argc | pop argc args, pop func, push result | Function call |
RETURN_VALUE | pop and return | Return from function |
BUILD_LIST size | pop size items, push list | Build a list from stack items |
GET_ITER | pop iterable, push iterator | Get iterator from iterable |
FOR_ITER target | push next or jump | Iterator loop |
Reading Bytecode for Optimization
Section titled “Reading Bytecode for Optimization”Understanding bytecode helps you write faster Python by revealing hidden costs:
import dis
# String concatenation in a loop (slow)def slow_join(items): result = "" for item in items: result += item return result
dis.dis(slow_join)# Each iteration: LOAD_FAST, LOAD_FAST, BINARY_ADD (creates new string), STORE_FAST# This is O(n^2) because each BINARY_ADD copies the entire accumulated string# Using join (fast)def fast_join(items): return "".join(items)
dis.dis(fast_join)# LOAD_GLOBAL "".join, LOAD_FAST items, CALL_FUNCTION 1, RETURN_VALUE# The join is implemented in C -- single call, O(n)String Interning
Section titled “String Interning”Interning is the process of deduplicating string objects so that equal strings share the same Memory. After interning, a is b is True for any two interned strings with the same value, and Dictionary lookups can use pointer comparison (a single machine instruction) instead of Character-by-character comparison.
Which Strings Get Interned
Section titled “Which Strings Get Interned”CPython automatically interns:
- Identifiers: Variable names, function names, class names, attribute names. Any string that appears as a Python identifier in source code.
- Compile-time constants: String literals that appear in the source code, but only if they look like identifiers (letters, digits, underscores, of reasonable length). The exact rules vary by CPython version.
- Attribute names: The strings used as dictionary keys in
__dict__objects.
Strings that are NOT automatically interned:
- Strings created at runtime via concatenation, formatting, or
str(). - Strings read from files, network, or user input.
- Strings created by
str.join()``str.replace()Or other string methods.
a = "hello"b = "hello"print(a is b) # True (interned at compile time)
c = "".join(["h", "e", "l", "l", "o"])d = "hello"print(c is d) # False (c was created at runtime, not interned)print(c == d) # True (value equality)sys.intern()
Section titled “sys.intern()”You can manually intern any string:
import sys
a = sys.intern("".join(["h", "e", "l", "l", "o"]))b = sys.intern("hello")print(a is b) # True (both manually interned)Manual interning is useful in performance-critical code that does many dictionary lookups with the Same keys. After interning, the dict lookup becomes a pointer comparison rather than a full string Comparison. The trade-off is that interned strings are never garbage collected (they live in the Intern table for the lifetime of the process), so interning unbounded numbers of unique strings Causes a memory leak.
Why Interning Matters
Section titled “Why Interning Matters”- Dictionary key performance: When CPython looks up a key in a dict, it first checks if the key and the stored key are the same object (
a is b). If they are, the lookup is complete without comparing characters. This is the “interning shortcut” and it is a significant optimization for attribute access and global variable lookups. - Memory savings: If the same string literal appears 1000 times in your source, interning ensures only one copy exists in memory.
- Identity comparison:
a is bcan replacea == bfor interned strings, which is faster (but less readable and not recommended in application code).
Small Integer Caching
Section titled “Small Integer Caching”CPython pre-allocates and caches integer objects in the range [-5, 256]. Every reference to an Integer in this range points to the same pre-allocated object.
a = 100b = 100print(a is b) # True
a = 257b = 257print(a is b) # False (most of the time)is vs ==
Section titled “is vs ==”is tests identity (pointer equality). == tests value equality (via __eq__). For integers, == Always does the right thing. is only works reliably for integers in the cached range.
a = 256b = 256print(a is b) # True (cached)print(a == b) # True
a = 257b = 257print(a is b) # Implementation-dependent (in most cases False)print(a == b) # True (always correct)Never use is for numeric comparison. Always use ==. The caching range is a CPython Implementation detail, not a language guarantee. Other implementations (PyPy, GraalPy) may use Different caching strategies or no caching at all.
Why This Exists
Section titled “Why This Exists”Small integers are extremely common in Python programs (loop counters, boolean results, short lists Indices, enum values). Caching them eliminates the overhead of allocating a new PyObject for every Occurrence. The range [-5, 256] was chosen empirically to cover the vast majority of use cases Without consuming excessive memory.
__dict__ vs __slots__
Section titled “__dict__ vs __slots__”The Default: __dict__
Section titled “The Default: __dict__”By default, every instance of a user-defined class has a __dict__ attribute — a regular Python Dictionary that stores the instance’s attributes. This dictionary is created when the instance is Allocated and grows as attributes are added.
class Point: def __init__(self, x, y): self.x = x self.y = y
p = Point(1, 2)print(p.__dict__) # {'x': 1, 'y': 2}print(type(p.__dict__)) # <class 'dict'>The memory overhead of __dict__ is significant:
- An empty
dictin CPython 3.12 consumes approximately 64 bytes (the dict struct itself plus an empty hash table). - Each entry adds approximately 50-70 bytes (key pointer, value pointer, hash, and the key and value objects themselves).
- For an object with 5 attributes, the
__dict__alone can consume 300-500 bytes on top of the base object overhead (56 bytes for thePyObjectheader plus instance-specific fields).
__slots__ for Memory Optimization
Section titled “__slots__ for Memory Optimization”__slots__ replaces the per-instance __dict__ with a fixed set of attribute descriptors. Each Attribute is stored in a pre-allocated slot in the instance’s memory layout, similar to a C struct. This eliminates the dict overhead and reduces per-instance memory by 40-60%.
class DensePoint: __slots__ = ("x", "y")
def __init__(self, x, y): self.x = x self.y = y
p = DensePoint(1, 2)print(p.x) # 1p.z = 3 # AttributeError: "DensePoint'' object has no attribute "z'print(hasattr(p, "__dict__")) # FalseMemory comparison:
import sys
class Normal: def __init__(self, x, y): self.x = x self.y = y
class Slotted: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
print(sys.getsizeof(Normal(1, 2))) # 56 (instance) + ~64 (dict) = ~120 bytesprint(sys.getsizeof(Slotted(1, 2))) # 56 (instance with inline slots) = 56 bytesWhat __slots__ Prevents
Section titled “What __slots__ Prevents”- Dynamic attribute assignment: You cannot add attributes not listed in
__slots__. This is a feature, not a bug — it prevents typos and enforces a fixed schema. - Default
__dict__: Instances have no__dict__unless you explicitly include"__dict__"in__slots__. - Default
__weakref__: Weak references to instances will not work unless you explicitly include"__weakref__"in__slots__.
class WeakRefable: __slots__ = ("x", "__weakref__")
import weakrefobj = WeakRefable()ref = weakref.ref(obj) # Works because "__weakref__" is in __slots____slots__ Inheritance Gotchas
Section titled “__slots__ Inheritance Gotchas”- Each class in the hierarchy must define its own
__slots__. If a base class omits__slots__Subclasses gain a__dict__regardless of their own__slots__declaration.
class Base: pass # No __slots__ -- instances have __dict__
class Child(Base): __slots__ = ("x",) # Ineffective! Instances still have __dict__ from Base
c = Child()c.y = 10 # Works -- __dict__ exists from Base- Slots are not inherited in the usual sense. A child class’s
__slots__only declares the new slots for that class. The parent’s slots are already part of the instance layout.
class Base: __slots__ = ("a",)
class Child(Base): __slots__ = ("b",)
c = Child()c.a = 1 # OK (from Base's slots)c.b = 2 # OK (from Child's slots)c.c = 3 # AttributeError__slots__is a class variable, not an instance variable. It is a tuple of strings stored on the class, and it is read by the metaclass when the class is created to configure the instance’s memory layout. You cannot set__slots__on an instance.
The GIL Internals
Section titled “The GIL Internals”The Global Interpreter Lock is a mutex that protects access to CPython’s internal state. Only the Thread holding the GIL can execute Python bytecode or manipulate Python objects. See the async chapter for a detailed treatment of the concurrency Implications. This section focuses on the implementation details.
How the GIL Is Implemented
Section titled “How the GIL Is Implemented”The GIL is a pthread_mutex (on Unix) or SRWLOCK (on Windows) wrapping a simple boolean flag. The Eval loop checks this flag on every N bytecode instructions (the “check interval”). As of CPython 3.12, the default check interval is adaptive and varies based on the workload, but the historical Default was 100 bytecode instructions (or 5ms of wall clock time, whichever comes first).
In CPython 3.2+, the GIL implementation switched from a simple tick counter to a more sophisticated “time-based” mechanism that reduces contention. The requesting thread waits using a condition Variable with a timeout, and the holding thread explicitly releases the GIL when the timeout Expires. This avoids the “thundering herd” problem where multiple waiting threads all wake up and Contend for the GIL simultaneously.
What Triggers GIL Release
Section titled “What Triggers GIL Release”The GIL is released (and later re-acquired) during:
- I/O operations:
open()``read()``write()``socket.recv()``socket.send()And all other blocking I/O. The C extension performing the I/O releases the GIL before the blocking call and re-acquires it after. time.sleep(): Explicitly releases the GIL.- C extension code: Any C extension can release the GIL using the
Py_BEGIN_ALLOW_THREADSandPy_END_ALLOW_THREADSmacros. NumPy, Pandas, and similar libraries do this for CPU-intensive computation. - Blocking C library calls:
ctypescalls to C functions release the GIL by default. subprocess``threading``multiprocessingprimitives: Wait operations on locks, events, queues, and semaphores release the GIL.
Why Removing the GIL Is Hard
Section titled “Why Removing the GIL Is Hard”The GIL is not just a performance limitation — it is deeply entangled with CPython’s design:
Reference counting safety. Without the GIL, two threads could simultaneously increment or decrement the same reference count, causing race conditions. Free-threaded Python (PEP 703) solves this with atomic reference count operations, which are slower than non-atomic operations and add significant overhead to every object operation.
C extension compatibility. Thousands of C extensions assume the GIL exists and do not use proper locking. Making them work in a free-threaded build requires auditing and potentially rewriting significant portions of code.
Dictionary and set safety. Hash table operations in CPython are not thread-safe without the GIL. Free-threaded builds must add per-dict locks or use lock-free data structures.
Frame and stack safety. The CPython eval loop operates on C-level stack frames. Without the GIL, two threads could corrupt each other’s stacks. Free-threaded builds must ensure that frame objects are properly isolated.
PEP 703: Free-Threaded Python (3.13+)
Section titled “PEP 703: Free-Threaded Python (3.13+)”PEP 703 (implemented in Python 3.13 as an experimental feature) removes the GIL. Key changes:
- Reference counts use atomic operations (slower per-operation, but enables parallelism).
- A per-object “borrowing” mechanism reduces atomic overhead for short-lived references.
- Many internal data structures use fine-grained locks instead of a single global lock.
- The GIL can be optionally re-enabled via
PYTHON_GIL=1environment variable. - C extensions must opt in to free-threaded support via a new API.
The performance trade-off is nuanced. Single-threaded code is slightly slower (due to atomic Reference counting). Multi-threaded CPU-bound code can be significantly faster (due to true Parallelism). Multi-threaded I/O-bound code sees minimal change (I/O already released the GIL).
As of Python 3.13, the free-threaded build is experimental and not recommended for production. Many Popular packages (NumPy, Pandas, etc.) do not yet fully support it. The expectation is that by Python 3.15-3.16, free-threading will be production-ready and the GIL will be optional by default.
Intuition
Section titled “Intuition”CPython is an interpreter, not a compiler. It reads your code, compiles it to bytecode — an intermediate representation — and then executes it one instruction at a time on a virtual machine. This virtual machine is like a stack of plates: operations push results on top and pop operands off. The GIL is a single key to a shared bathroom — only one thread can hold it, so only one thread can execute Python code at a time. Reference counting is Python’s memory janitor: every time something points to an object, the refcount goes up; every time it stops pointing, the refcount goes down. When refcount hits zero, the object is immediately cleaned up. This is fast but cannot handle circular references, which is why a periodic garbage collector exists as a backup.
Common Pitfalls
Section titled “Common Pitfalls”1. Relying on implementation-specific object identity.
a = 256b = 256assert a is b # True (CPython), but not guaranteed
a = 257b = 257assert a is b # In most cases False, but not guaranteedThe integer caching range, string interning behavior, and tuple interning behavior are all CPython Implementation details. Write == for value comparison and is only for None checks and sentinel Objects.
2. Assuming __del__ will be called promptly.
Reference counting means __del__ is called immediately when the last reference drops — most of The time. But if the object is in a reference cycle, __del__ is not called until the garbage Collector runs, which may be much later or never (if the GC is disabled).
import gc
class Leaky: def __del__(self): print("cleaned up")
a = Leaky()a.self_ref = a # Cycledel a# "cleaned up" is NOT printed yet
gc.collect()# "cleaned up" is printed now (if the GC can break the cycle)3. Using gc.get_objects() in production.
gc.get_objects() returns a list of all objects tracked by the garbage collector. In a long-running Process, this list can contain millions of objects. Converting it to a list duplicates every Reference, doubling memory usage temporarily. Use it only for debugging.
4. Misunderstanding sys.getrefcount.
import sys
x = [1, 2, 3]print(sys.getrefcount(x))# Returns 2, not 1: "x'' holds one reference, getrefcount"s argument holds anotherThe returned count is always at least 2 for a local variable: one for the variable itself, one for The temporary reference created by passing it to getrefcount.
5. Forgetting that __slots__ must be defined on every class in the hierarchy.
If any class in the MRO lacks __slots__Instances gain a __dict__Negating the memory benefit For the entire hierarchy below that class. This is a silent failure — no error is raised, but Memory usage is higher than expected.
6. Over-interning strings.
import sys
for line in open("large_file.txt"): sys.intern(line.strip()) # Memory leak: interned strings are never freedThe intern table grows without bound. Only intern strings that are truly reusable across the Lifetime of the process, or use a bounded LRU cache as a manual alternative.
7. Assuming bytecode is stable across Python versions.
Bytecode instructions change between Python versions. Code that depends on specific opcodes (e.g., For instrumentation or analysis) will break when upgrading. Use the dis module’s abstracted Interface rather than raw opcode numbers, and pin your Python version if you depend on specific Bytecode behavior.
8. Ignoring the GC’s impact on real-time performance.
A full garbage collection (gc.collect()) can take milliseconds to seconds depending on the number Of tracked objects. In latency-sensitive applications (trading systems, game loops, real- time Signal processing), an unexpected GC pause can cause deadline misses. Profile your GC behavior with gc.get_stats() (Python 3.4+) and tune thresholds accordingly.
Summary
Section titled “Summary”This topic covers the biological principles of python internals, including key concepts, experimental evidence, and real-world applications.
Key concepts include:
- key biological principles and concepts
- experimental methods and data analysis
- applications of biology in medicine and industry
- ethical considerations in biological research
- the relationship between structure and function
Success requires the ability to recall specific factual content, apply knowledge to novel scenarios, and evaluate experimental evidence critically.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Types and Variables — The PyObject header, reference counting, and type dispatch explain why dynamic typing has a runtime cost.
- Collections — List growth strategy, dict hash tables, and set internals are concrete applications of the memory model described here.
- Control Flow — Bytecode instructions for loops, conditionals, and exception handling are generated from the syntax constructs in control flow.
- Dicts, Sets, and Collections Deep Dive — Compact dict design, hash randomisation, and the slots mechanism are implementation details of the object model.