1
What is GIL — Global Interpreter Lock?
Hard 5 min
GIL is a mutex inside CPython that allows only ONE thread to execute Python bytecode at any given moment — even on multi-core machines.

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.

SINGLE CORE (GIL) MULTI CORE ILLUSION T1 runs T2 runs T1 runs T2 runs Takes turns — NOT parallel 🔒 GIL mutex Core 1 — Process A running Core 2 — Process B running Core 3 — Process C running True parallelism — multiprocessing Each process has its own GIL No contention, fully parallel
Left: Threads fight over one GIL. Right: Processes run on separate cores independently.
What Interviewers Check
  • 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.
Follow-up Questions
Q: Why not just remove the GIL?
Tried many times. Reference counting everywhere in CPython makes it extremely hard. Each object's refcount update would need its own lock — massive complexity. Larry Hastings' "Gilectomy" project showed ~30x slowdown on single-threaded code due to lock overhead.
Q: How do NumPy/SciPy bypass the GIL?
C extensions can release the GIL manually using Py_BEGIN_ALLOW_THREADS macro. NumPy does this for heavy array computations — so NumPy operations CAN run truly in parallel in threads.
⚠ Traps Candidates Fall Into
  • 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.
🏆 Competitive Edge

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.

2
Multithreading vs Multiprocessing
Hard 5 min
Threads share memory, are fast to create, but bottlenecked by GIL for CPU work. Processes have isolated memory, bypass GIL, but are heavier to create and need IPC to communicate.

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
What Interviewers Check
  • 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.
⚠ Traps Candidates Fall Into
  • 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.
3
Mutable vs Immutable Objects
Medium 4 min
Immutable objects can't be changed after creation (int, str, tuple). Mutable objects can be changed in-place (list, dict, set). This has real consequences for function arguments and dict keys.

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.

Immutable: int, float, bool, str, tuple, frozenset, bytes
Mutable: list, dict, set, bytearray, and most user-defined objects
What Interviewers Check
  • 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.
python
# 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
⚠ Traps Candidates Fall Into
  • 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.
4
Difference Between is and ==
Easy 3 min
== 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.

python
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)
What Interviewers Check
  • Use is only for None checks (if x is None) — that's the idiomatic Python way.
  • Never use is to compare strings or integers in production — behavior depends on implementation.
  • is checks id() — memory address. == calls __eq__() method — customizable.
  • PEP 8 explicitly says: use is for None/True/False comparisons, not ==.
⚠ Traps Candidates Fall Into
  • 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 use if x is None.
5
Deep Copy vs Shallow Copy
Medium 4 min
Shallow copy creates a new container but keeps references to the same inner objects. Deep copy duplicates everything recursively — fully independent.

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.

ORIGINAL outer list [1, 2, 3] SHALLOW COPY new outer SAME inner object! DEEP COPY new outer [1, 2, 3] NEW independent copy
Shallow: new container, same inner objects. Deep: everything new.
What Interviewers Check
  • 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.
⚠ Traps Candidates Fall Into
  • 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.
6
How Python Memory Management Works
Hard 5 min
Python uses reference counting as primary GC + a cyclic garbage collector for objects that reference each other in a cycle. Memory is managed by a private heap via CPython's memory allocator.

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.

Python Object ob_refcnt: 2 ob_type: <str> value: "hello" x = ... y = ... 2 references → refcnt=2 refcnt → 0 memory freed immediately
Reference counting: when all references removed, object is freed instantly.
What Interviewers Check
  • 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.
⚠ Traps Candidates Fall Into
  • 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 x frees memory — it only removes a reference. Memory is freed only when refcount reaches 0.
🏆 Competitive Edge

__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.

7
What Happens When Python Code Executes Internally?
Hard 5 min
Source → Tokenizer → Parser → AST → Compiler → Bytecode (.pyc) → CPython VM interprets bytecode one instruction at a time.

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).

.py source Tokenizer Parser → AST Compiler .pyc bytecode CPython VM
The full Python execution pipeline — .pyc is cached to skip tokenize/parse/compile on re-runs.
What Interviewers Check
  • __pycache__ stores compiled bytecode — Python skips compilation if source hasn't changed (based on mtime and size).
  • dis module 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.
🏆 Competitive Edge

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.

8
List vs Tuple
Easy 3 min
List is mutable, dynamic, slightly slower. Tuple is immutable, fixed-size, faster access, and can be a dict key. Choose based on whether your collection should change.

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
What Interviewers Check
  • 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, 2 is a tuple on the right side.
  • Named tuples (collections.namedtuple or typing.NamedTuple) give you tuple performance with field names.
⚠ Traps Candidates Fall Into
  • (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.
9
Generators & Why They Are Memory Efficient
Medium 5 min
A generator produces values one at a time on demand (lazy evaluation) instead of building the whole collection in memory upfront — perfect for large or infinite sequences.

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.

python
# 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)
What Interviewers Check
  • yield pauses execution and saves function state. return ends 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.
Follow-up Questions
Q: What's the difference between a generator and an iterator?
All generators are iterators, but not all iterators are generators. An iterator is any object implementing __iter__ and __next__. A generator is a convenient way to create iterators using yield — no class boilerplate needed.
Q: Can you send values into a generator?
Yes — 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.
⚠ Traps Candidates Fall Into
  • 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.
🏆 Competitive Edge

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.

10
Interpreter vs Compiler in Python
Medium 4 min
Python is both: it compiles source code to bytecode (compiler step), then interprets that bytecode line-by-line using the CPython VM (interpreter step). It's not a "pure interpreter."

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
What Interviewers Check
  • Python is not a "pure interpreter" — there is a compilation step to bytecode, just not to machine code.
  • The .pyc files 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.
Follow-up Questions
Q: If Python is interpreted, why do we get SyntaxErrors before running?
Because Python fully parses and compiles the entire file to bytecode before execution starts. Syntax errors are caught at compile time. Only runtime errors (NameError, TypeError) appear during execution.
⚠ Traps Candidates Fall Into
  • 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.
🏆 Competitive Edge

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.

Start practice
Home 01. DBMS 02. OOPs 03. Computer Networks 04. Operating System 05. C++ Core 05. Java Core 05. Python Core 06. Puzzles 07. Linux 08. Git 09. SQL 10. Projects 11. System Design 12. DSA Concepts