CPython (the standard Python) uses reference counting for memory management. Every object has a counter — how many variables point to it. When that counter hits zero, the object is freed. The problem? Reference count updates are NOT thread-safe by default. Two threads incrementing/decrementing the same counter simultaneously = corruption.
The GIL is the brutal but simple fix: "Only one thread runs Python bytecode at a time, period." It's like a single baton in a relay race — a thread must hold the GIL to run, and it releases it periodically (every 5ms in Python 3.2+) or during I/O so other threads get a turn.
This means Python threads are concurrent (they take turns) but NOT parallel (they don't run simultaneously on multiple cores). For CPU-heavy tasks, threads give you zero speedup — you're still using one core. For I/O-heavy tasks (network calls, file reads), threads work fine because the GIL is released during I/O waits.
- GIL only exists in CPython (reference implementation). Jython and PyPy-STM don't have it.
- I/O-bound tasks: threads work well — GIL released during I/O wait, other threads run.
- CPU-bound tasks: threads are useless — use multiprocessing to get real parallelism.
- GIL is released every ~5ms (sys.getswitchinterval) or explicitly during I/O and C extensions.
- Python 3.13 is introducing a "free-threaded" mode (no GIL) — a big upcoming change.
- Saying "Python can't do multithreading" — wrong. It can. It just can't do CPU-parallel multithreading.
- Saying GIL prevents ALL concurrency — no, async I/O (asyncio) is concurrent and doesn't even use threads.
Python 3.13 (Oct 2024) ships with an experimental free-threaded
build (--disable-gil). This is the most significant
Python change in decades. If you know this and mention PEP 703,
you'll stand out at any senior-level interview.
This is the direct consequence of the GIL. For
I/O-bound tasks (web scraping, API calls,
reading files), use threading — the GIL gets
released during I/O, threads can overlap their wait times, and
you get real speedup with low overhead.
For CPU-bound tasks (image processing, number
crunching, data transformation), use
multiprocessing — each process is a separate Python
interpreter with its own GIL, so they run truly in parallel on
multiple cores. The cost: each process has its own memory copy,
and communication between processes requires pickling
(serializing) data.
There's also asyncio — a third option. It's single-threaded, single-process, but uses event loops and coroutines. Perfect when you have thousands of I/O operations and the overhead of threads is too high. Think web servers handling 10k concurrent connections.
| Aspect | threading | multiprocessing | asyncio |
|---|---|---|---|
| GIL impact | Bottlenecked for CPU | No GIL (separate process) | No threads, no GIL issue |
| Memory | Shared | Separate copy per process | Single thread, shared |
| Best for | I/O-bound tasks | CPU-bound tasks | High-concurrency I/O |
| Overhead | Low | High (process fork) | Very low |
| Communication | Shared variables (with locks) | Queue, Pipe, Manager | Coroutines, queues |
- The "use threads for I/O, processes for CPU" rule — this is the answer to 90% of interview scenarios.
- multiprocessing.Pool maps work across processes easily for parallel CPU tasks.
- concurrent.futures gives a unified interface for both threads (ThreadPoolExecutor) and processes (ProcessPoolExecutor).
- Shared state in multiprocessing needs Manager objects or shared memory — can't just use a global variable.
- Using threads for heavy CPU computation expecting speedup — you'll get slowdown due to GIL contention and context switching overhead.
-
Forgetting that multiprocessing on Windows uses "spawn" by
default (not fork) — your code must be inside
if __name__ == "__main__"block or you get infinite spawning.
In Python, everything is an object. When you do
x = 5, x is a variable that points to the
integer object 5. When you do x = x + 1, you're not
changing the 5 — you're making x point to a new integer object
6. The original 5 is untouched. That's immutability.
With a list, my_list.append(4) actually modifies
the same list object in memory. Any other variable pointing to
that same list will see the change. This is why passing a list
to a function and modifying it inside changes the original —
they both point to the same object.
Python uses integer caching (small ints -5 to
256 are pre-created and shared). So
a = 5; b = 5; a is b is True — both point to the
same cached int object. This can confuse people with the
is operator.
Mutable: list, dict, set, bytearray, and most user-defined objects
- Immutable objects are hashable → can be used as dict keys or set elements. Mutable objects cannot.
- Default mutable arguments in functions is a classic Python trap — the default is created once, not per call.
- String concatenation in a loop creates a new string object each time — use join() or list + join instead.
- Tuple is immutable but if it contains a mutable object (like a list), that inner list can still be changed.
# Classic mutable default argument trap def add_item(item, lst=[]): # BAD! [] created once lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2] ← same list! # FIX: use None as default def add_item_fixed(item, lst=None): if lst is None: lst = [] lst.append(item) return lst
- Mutable default argument in function definition — one of the most common Python bugs. The list is shared across all calls.
- A tuple containing a list is "immutable" but the list inside can still be modified. The tuple can't grow/shrink, but its contents can mutate if they're mutable objects.
is and ==
== checks if two objects have the same value.
is checks if they are literally the same object in
memory (same id).
Think of == as "do these two objects look the
same?" and is as "are these the exact same person,
not just a twin?" Two different list objects with identical
contents will pass == but fail is,
because they're two separate objects in memory.
Python caches small integers (-5 to 256) and interned strings,
so a = 5; b = 5; a is b will be True. But
a = 1000; b = 1000; a is b might be False — Python
doesn't cache large integers. This behavior is an implementation
detail, not a language guarantee.
a = [1, 2, 3] b = [1, 2, 3] c = a print(a == b) # True — same content print(a is b) # False — different objects print(a is c) # True — c points to same object as a # Small int caching x = 100; y = 100 print(x is y) # True — cached x = 1000; y = 1000 print(x is y) # False — not cached (usually)
-
Use
isonly for None checks (if x is None) — that's the idiomatic Python way. -
Never use
isto compare strings or integers in production — behavior depends on implementation. -
ischecksid()— memory address.==calls__eq__()method — customizable. -
PEP 8 explicitly says: use
isfor None/True/False comparisons, not==.
-
Relying on
"hello" is "hello"being True — it usually is due to string interning, but it's not guaranteed. Different Python implementations may behave differently. -
Using
if x == None— works but not Pythonic. Always useif x is None.
Imagine you have a list of lists. Shallow copy gives you a new outer list, but the inner lists are the same objects as the original. Modify an inner list — both copies see the change.
Deep copy creates completely new objects at every level. Change
anything in the deep copy — original is untouched. Use
copy.copy() for shallow,
copy.deepcopy() for deep.
-
copy.copy()→ shallow copy.copy.deepcopy()→ deep copy. -
For a 1D list of immutables (like
[1,2,3]), shallow copy is effectively the same as deep — there's nothing nested to share. - Deep copy can be slow for large nested structures. Profile before using everywhere.
-
Slice
list[:],list(original),list.copy()— all shallow copies.
-
Thinking
b = a[:]makes an independent copy — it's shallow. Nested mutable objects are still shared. - Deep copy on objects with circular references can cause infinite recursion — Python's deepcopy handles this, but custom __deepcopy__ must too.
Every Python object has a reference count — a number tracking
how many variables/containers point to it. When you do
x = obj, obj's refcount goes up. When x goes out of
scope or you do del x, refcount goes down. When
refcount hits 0 — the object is immediately freed. No
GC pause needed.
But reference counting alone can't handle circular references. If object A has a reference to B, and B references back to A, even when all external references are gone, both have refcount 1. They'll never be freed. Python's cyclic GC runs periodically to detect and clean these up.
Memory is not directly requested from the OS for each object.
Python maintains a private heap and uses an allocator called
pymalloc that manages small objects (≤512 bytes)
efficiently using fixed-size memory pools — much faster than
calling malloc for every tiny object.
- Primary GC = reference counting (immediate dealloc when refcnt hits 0). Backup GC = cyclic collector.
-
sys.getrefcount(obj)gives reference count — it's always 1 higher than you'd expect because calling the function creates a reference. - Cyclic GC has 3 generations (young, middle, old) — Gen 0 collected most often. Long-lived objects promoted to Gen 2.
-
gc.disable()disables cyclic GC — safe if your code has no circular references (performance optimization). - Memory fragmentation is mitigated by pymalloc's object pools for small objects.
- Thinking Python GC works like Java GC (stop-the-world, generational only) — Python's reference counting is deterministic and immediate. Java's is not.
-
Assuming
del xfrees memory — it only removes a reference. Memory is freed only when refcount reaches 0.
__slots__ in Python classes bypasses the default
__dict__ per object, saving significant memory (up
to 40–50%) when creating thousands of instances. At scale (a
million objects), this matters a lot. Mention this for system
design interviews asking about Python performance.
When you run python script.py, Python first reads
your source code and tokenizes it — breaks it
into meaningful chunks (keywords, identifiers, operators). Then
a parser turns those tokens into an
Abstract Syntax Tree (AST) — a tree structure
representing the logical structure of your code.
A compiler then converts the AST into
bytecode — a sequence of low-level instructions
for the CPython VM. This bytecode is cached in
__pycache__/*.pyc files so next time you run the
same script, the tokenize/parse/compile steps are skipped.
Finally, the CPython Virtual Machine — basically a giant loop — reads bytecode instructions one by one and executes them. Unlike Java's JVM, CPython is a pure interpreter. There's no JIT compilation by default (though PyPy has JIT).
-
__pycache__stores compiled bytecode — Python skips compilation if source hasn't changed (based on mtime and size). -
dismodule lets you see bytecode:import dis; dis.dis(my_function) - CPython has no JIT — that's why PyPy (with JIT) can be 5–50x faster for CPU-intensive code.
-
The CPython VM's main loop is
ceval.c— a giant switch-case over bytecode opcodes.
Python 3.11 introduced "specializing adaptive interpreter" — the
VM observes types at runtime and specializes instructions (e.g.,
LOAD_ATTR becomes LOAD_ATTR_MODULE for
known modules). This is a step toward JIT without a full JIT.
Python 3.13 adds an experimental JIT compiler. Mention this to
show you follow CPython evolution.
The practical rule: use a list when you have a collection that might grow or change (a list of user inputs, results to accumulate). Use a tuple when the data is fixed (coordinates, RGB color, a function returning multiple values, dict keys).
Because tuples are immutable, CPython can make some optimizations — tuple literals are often pre-created at compile time rather than at runtime. Tuple creation is faster, and tuples take slightly less memory than equivalent lists.
| Aspect | List | Tuple |
|---|---|---|
| Mutability | Mutable | Immutable |
| Syntax | [1, 2, 3] |
(1, 2, 3) |
| Dict key? | No (not hashable) | Yes (if contents hashable) |
| Methods | append, extend, pop, sort… | count, index only |
| Memory | Larger (over-allocates) | Smaller (exact fit) |
| Performance | Slower iteration | Slightly faster |
- Lists over-allocate memory (double when full) to make appends O(1) amortized. Tuples allocate exact size.
-
A one-element tuple needs a trailing comma:
(1,)not(1)— that's just parentheses around an int. -
Tuple packing/unpacking is Pythonic:
a, b = 1, 2is a tuple on the right side. - Named tuples (collections.namedtuple or typing.NamedTuple) give you tuple performance with field names.
-
(1)is an integer, NOT a tuple.(1,)is a tuple. Miss that comma and you'll get a baffling type error. - A tuple containing a list is NOT fully immutable — the list inside can still be modified. The tuple itself can't be resized, but its mutable contents can change.
Normal function: call it → runs to completion → returns a value.
Generator function: call it → returns a generator object
(nothing executes yet). Every time you call
next() on it, execution runs until the next
yield, pauses there, and hands back the yielded
value. The function's local state is preserved between calls.
The magic: only one value exists in memory at a time. If you need to process a 10GB log file line by line, a generator gives you one line at a time. A list would try to load all 10GB into RAM and crash.
Generator expressions are like list comprehensions but with
() instead of []:
(x*2 for x in range(1000000)) uses constant memory.
[x*2 for x in range(1000000)] creates a
million-element list immediately.
# List: creates ALL values in memory at once squares_list = [x**2 for x in range(1_000_000)] # ~8MB # Generator: creates values ONE AT A TIME squares_gen = (x**2 for x in range(1_000_000)) # ~120 bytes! # Generator function with yield def read_big_file(path): with open(path) as f: for line in f: yield line.strip() # one line at a time # Only one line in memory at any moment for line in read_big_file("10gb_log.txt"): process(line)
-
yieldpauses execution and saves function state.returnends function. -
Generators are iterators — they implement
__iter__and__next__automatically. - Once exhausted, a generator is done. You can't reset it — you'd need to create a new generator.
-
yield from(Python 3.3+) delegates to a sub-generator — useful for recursive generators and coroutines. - Generators are the foundation of async/await coroutines in Python.
__iter__ and __next__. A generator
is a convenient way to create iterators using
yield — no class boilerplate needed.
gen.send(value) resumes the generator AND
sends a value back as the result of the
yield expression inside. This is how coroutines
work in Python.
-
Trying to get the length of a generator — generators have no
len(). You'd have to exhaust it, which defeats the purpose. - Expecting to iterate a generator twice — it's exhausted after the first pass. Assign to a list if you need multiple passes.
Python's itertools module is a treasure chest of
memory-efficient lazy operations: itertools.chain,
islice, takewhile,
groupby. Mentioning that you'd compose generators
with itertools for pipeline-style data processing shows
senior-level Python knowledge.
This question trips many people because the answer is nuanced. Traditionally, compilers translate source code to machine code all at once before running (C, C++, Rust). Interpreters execute source code line-by-line without producing a separate binary (old BASIC interpreters).
Python is a hybrid. It has a compilation phase (source → bytecode) that happens when you run the script. Then it has an interpretation phase (bytecode → execution) by the CPython VM. The bytecode is not machine code — it's still an intermediate language that the VM interprets at runtime.
CPython = interpreted. PyPy = JIT compiled — takes Python bytecode and compiles hot code paths to native machine code on the fly. This is why PyPy can be dramatically faster for CPU-bound code. Cython = transpiles Python-like code to C, then compiles to machine code. Nuitka = compiles Python to C/machine code.
| Implementation | Mechanism | Speed | Use case |
|---|---|---|---|
| CPython | Bytecode interpreted | Baseline | Default, general purpose |
| PyPy | JIT compiled | 5–50× faster | CPU-heavy Python |
| Cython | Python→C→machine code | Near C speed | Performance-critical modules |
| Nuitka | Python→C compiled | Near C speed | Full program compilation |
- Python is not a "pure interpreter" — there is a compilation step to bytecode, just not to machine code.
-
The
.pycfiles in__pycache__are the compiled bytecode artifacts. - JIT compilation in PyPy profiles which code runs most and compiles only hot paths — avoids compiling rarely-executed code.
- CPython's lack of JIT is a design choice for simplicity and portability, not an oversight.
- Saying Python is "purely interpreted" — it has a compilation step. The correct term is "bytecode-compiled and VM-interpreted."
- Thinking CPython and Python are the same — Python is the language spec; CPython is the reference implementation. Jython, PyPy, MicroPython are all Python implementations.
Python 3.11 (30–60% faster than 3.10) and 3.12's specializing adaptive interpreter significantly narrowed the gap between CPython and PyPy. The CPython team is taking performance seriously now — knowing version-by-version improvements shows genuine engagement with the language.
Ready to test Python Core?
Check the Python internals and practical gotchas while they are fresh.