QuizCluster
Programming LanguagesJunior Python Developer to Senior/Staff Backend Engineer17 min read

Python Interview Preparation: Complete Guide for 2026

From the GIL and Memory Internals to Decorators, Async Concurrency & Modern Typing

Priya Chandrasekaran
Senior Python Backend Engineer & PyCon Speaker
10+ Years Building High-Throughput Python Services
Prep Timeline
5 to 7 Weeks
Format
Language Internals, Concurrency, OOP & Live Coding
Conversion
+80% Technical Pass Rate
Python Interview Preparation: Complete Guide for 2026
Executive Summary & Key Takeaways

What You Must Master to Clear This Track

  • Explain reference counting and the generational garbage collector together, and know exactly when the GIL is released.
  • Understand why threading suits I/O-bound work, multiprocessing suits CPU-bound work, and asyncio suits massively concurrent I/O.
  • Master list/dict/set internals: dynamic array over-allocation, open-addressing hash tables, and average O(1) lookups.
  • Be fluent in decorators, generators, and context managers as first-class tools, not just syntax sugar.
  • Know Python 3.12+ changes: PEP 695 generic syntax, per-interpreter GIL, and the free-threaded (no-GIL) build effort.
Structured Preparation Timeline

Step-by-Step Study Plan

Follow this sequential roadmap designed to take you from core foundations to advanced architecture and mock interviews.

Phase 1 (Weeks 1-2)

Language Internals & Built-in Data Structure Mastery

Core Language, Memory Model & Data Structures

GIL mechanics, reference counting plus the generational GC, and the internal implementation of list, dict, set, and tuple.

Key Milestones
  • Explain reference counting and the 3-generation cyclic GC well enough to debug a reference-cycle memory leak.
  • Describe dict's open-addressing hash table and why average-case lookups are O(1).
  • Identify when to use list vs deque vs tuple vs set based on required operations and complexity.
Recommended Actions
  • Rewrite 5 common coding problems focusing on choosing the right built-in structure, not just correctness.
  • Use the tracemalloc module to inspect a script's memory allocations and practice explaining the output.
Phase 2 (Weeks 3-4)

Pythonic Idioms & Object Model Depth

Decorators, Generators, OOP & Advanced Constructs

Custom decorators, generators and context managers, dunder methods, metaclasses, and MRO / C3 linearization.

Key Milestones
  • Write a parameterized decorator and a contextlib.contextmanager-based resource manager from scratch.
  • Explain the descriptor protocol behind @property and bound methods.
  • Trace Method Resolution Order for a diamond multiple-inheritance hierarchy.
Recommended Actions
  • Implement a small class hierarchy using __init_subclass__ before reaching for a custom metaclass.
  • Practice explaining WHY functools.wraps matters, not just that it exists.
Phase 3 (Weeks 5-6)

Concurrency Models, Modern Typing & Production Readiness

Concurrency, Typing & System-Level Python

threading vs multiprocessing vs asyncio trade-offs, asyncio event loop internals, typing/Protocol, and Python 3.12+ features.

Key Milestones
  • Build a small asyncio program using gather, wait_for, and an executor offload for a blocking call.
  • Convert a TypeVar/Generic class to PEP 695 syntax and explain Protocol-based structural typing.
  • Complete a mock interview covering GIL trade-offs, memory leak diagnosis, and one live coding round in Python.
Recommended Actions
  • Benchmark the same CPU-bound task under threading, multiprocessing, and single-threaded code to internalize the GIL's real impact.
  • Review Python 3.12/3.13 changelog highlights (PEP 695, PEP 701, the free-threaded build) and be ready to discuss trade-offs.
Deep-Dive Architecture & Concepts

1. The GIL, Reference Counting & Generational Garbage Collection

Nearly every Python interview probes whether you understand CPython's memory model beyond 'it just works' -- the GIL, reference counting, and the cyclic garbage collector working together.

The Global Interpreter Lock (GIL)

A mutex ensuring only one thread executes Python bytecode at a time in CPython, simplifying refcount safety at the cost of true multi-core parallelism for pure Python code.

Reference Counting

Every PyObject carries an ob_refcnt; it is incremented on each new reference and decremented when one goes out of scope, freeing the object the instant the count hits zero.

Generational Cyclic GC

A supplementary collector groups objects into 3 generations (0, 1, 2) and periodically scans for unreachable reference cycles that refcounting alone can never free.

GIL Release Points

The GIL is released around blocking C-level I/O calls and at a configurable switch interval (sys.getswitchinterval, default 5ms), which is why threading still helps I/O-bound workloads.

CPython GIL Thread Scheduling Under I/O-Bound Load

How two Python threads interleave execution around a single blocking I/O call.

1
Thread A acquires the GIL
Thread A begins executing Python bytecode instructions while holding the single interpreter-wide lock.
2
Switch interval elapses
After roughly sys.getswitchinterval() (default ~5ms) of continuous bytecode execution, the interpreter flags a release request for fairness.
3
Blocking I/O call triggers explicit release
Thread A calls socket.recv() or file.read(); the underlying C implementation explicitly releases the GIL before blocking on the OS syscall.
4
Thread B acquires the GIL
With the GIL free, the OS scheduler wakes Thread B, which acquires it and begins executing its own Python bytecode.
5
Thread A reacquires the GIL post I/O
When the syscall returns, Thread A re-enters the contention queue and waits for the GIL before resuming bytecode execution where it left off.
Observing Reference Counting vs a Reference Cycle
python
import gc
  import sys
  
  class Node:
      def __init__(self, name):
          self.name = name
          self.parent = None
  
      def __del__(self):
          print(f"Node {self.name} destructor called")
  
  def make_cycle():
      a = Node("A")
      b = Node("B")
      a.parent = b
      b.parent = a  # reference cycle: each node's refcount never drops to 0
      print("refcount(a):", sys.getrefcount(a))
  
  make_cycle()
  print("Local names 'a' and 'b' are gone, but the cycle keeps both alive")
  gc.collect()  # the generational cyclic collector finds and frees the cycle
  print("Cycle collected")
Why it matters: Refcounting alone frees objects the instant their count hits zero, but it can never detect a and b holding references to each other. The generational cyclic collector periodically scans for unreachable cycles like this and reclaims them, which is why the destructors only fire after gc.collect() runs.
Interviewer Insights & Pro Tips
  • When asked 'does Python have garbage collection?', answer with both mechanisms: immediate refcounting plus a periodic generational cycle collector -- most candidates only mention one.
  • sys.getrefcount(obj) always reports one more than you expect, because passing obj as an argument creates a temporary extra reference.
Red Flags & Common Pitfalls
  • Assuming the GIL means Python programs can never use multiple cores -- it only serializes pure Python bytecode; C extensions and separate processes are unaffected.
  • Defining __del__ on objects that participate in reference cycles and assuming they'll never be collected -- since PEP 442 (Python 3.4+) the cyclic GC safely finalizes these too, just with undefined ordering.
Deep-Dive Architecture & Concepts

2. Mutable vs Immutable Types & Data Structure Internals

Beyond syntax, interviewers want to see that you understand what list, dict, and set actually are under the hood, and why that dictates their performance characteristics.

Mutable vs Immutable

list, dict, set, and bytearray can change in place; tuple, str, frozenset, and numeric types cannot. Only immutable, hashable objects are safe as dict keys or set members.

list: Dynamic Array with Over-Allocation

A list is a contiguous array that over-allocates extra capacity on growth, giving amortized O(1) append but O(n) insert/delete anywhere except the end.

dict: Open-Addressing Hash Table

Since Python 3.7, dict combines a sparse index array with a dense, insertion-ordered entry array; it resizes once roughly two-thirds full to keep probe sequences short.

set: The Same Hash Table, Keys Only

set reuses dict's open-addressing machinery but stores only keys, giving average O(1) add/contains -- far faster than scanning a list for membership.

The Mutable Default Argument Trap
python
# BUG: the default list is created ONCE, when the function is defined
  def add_item(item, bucket=[]):
      bucket.append(item)
      return bucket
  
  print(add_item("a"))  # ['a']
  print(add_item("b"))  # ['a', 'b']  <- unexpected! same list reused every call
  
  # FIX: use a None sentinel and create a fresh object per call
  def add_item_fixed(item, bucket=None):
      if bucket is None:
          bucket = []
      bucket.append(item)
      return bucket
  
  print(add_item_fixed("a"))  # ['a']
  print(add_item_fixed("b"))  # ['b']
Why it matters: Default argument values are evaluated exactly once, when the def statement executes, and stored on the function object itself. A mutable default becomes shared state that accumulates across unrelated calls; the idiomatic fix is a None sentinel with lazy initialization inside the function body.
Interviewer Insights & Pro Tips
  • Use collections.deque for O(1) appends/pops from both ends instead of list.insert(0, x) or list.pop(0), which are O(n).
  • Prefer a tuple or frozenset as a dict key when you need a hashable, immutable grouping of values.
Red Flags & Common Pitfalls
  • Assuming `a is b` is a safe replacement for `a == b` on anything beyond None and small cached integers.
  • Forgetting that slicing a list (lst[:]) or calling dict(d) makes a shallow copy -- nested mutable objects are still shared with the original.
Deep-Dive Architecture & Concepts

3. Decorators, Generators & Context Managers

These three constructs are what separate someone who writes working Python from someone who writes idiomatic, production-grade Python -- and they show up constantly in live coding rounds.

Decorators Are Just Higher-Order Functions

`@decorator` above a def is pure syntax sugar for `func = decorator(func)`; a parameterized decorator is a factory function that returns the real decorator.

Generators Yield Lazily

A function using yield returns a generator object immediately; its frame (locals + instruction pointer) is suspended between yields, giving O(1) memory regardless of sequence length.

yield from Delegation

yield from forwards values, exceptions, and the return value transparently to/from a sub-generator, simplifying generator composition.

Context Managers Guarantee Cleanup

`with obj:` calls __enter__(), runs the block, and unconditionally calls __exit__() even on an exception -- equivalent to a try/finally, but reusable and declarative.

A Parameterized Retry Decorator and a Generator-Based Context Manager
python
import time
  import functools
  from contextlib import contextmanager
  
  def retry(times=3, exceptions=(Exception,)):
      """Decorator factory: retries the wrapped function on failure."""
      def decorator(func):
          @functools.wraps(func)  # preserves __name__, __doc__, signature
          def wrapper(*args, **kwargs):
              last_exc = None
              for attempt in range(1, times + 1):
                  try:
                      return func(*args, **kwargs)
                  except exceptions as exc:
                      last_exc = exc
                      print(f"Attempt {attempt} failed: {exc}")
              raise last_exc
          return wrapper
      return decorator
  
  @retry(times=3, exceptions=(ConnectionError,))
  def fetch(url):
      ...
  
  @contextmanager
  def timed_block(label):
      start = time.perf_counter()
      try:
          yield  # control returns to the 'with' block body here
      finally:
          elapsed = time.perf_counter() - start
          print(f"{label} took {elapsed:.4f}s")
  
  with timed_block("fetch"):
      fetch("https://api.example.com")
Why it matters: retry is a decorator factory: it takes arguments and returns the actual decorator, which returns the wrapped function. timed_block uses contextlib.contextmanager so the code before yield runs as __enter__ and the finally block runs as __exit__, guaranteeing the timer prints even if fetch raises.
Interviewer Insights & Pro Tips
  • Always apply functools.wraps in a decorator you write, or introspection tools, help(), and pickling break because __name__/__doc__ point to the wrapper instead of the original function.
  • Generator expressions `(x for x in items)` are lazy and O(1) memory versus list comprehensions, which materialize the entire list up front.
Red Flags & Common Pitfalls
  • Reusing an already-exhausted generator -- iterating over it a second time silently yields nothing instead of raising an error.
  • Forgetting that a `with` block's exception is passed to __exit__(exc_type, exc_val, exc_tb), and that returning a truthy value there silently swallows it.
Deep-Dive Architecture & Concepts

4. OOP Internals, Concurrency Models & Modern Python (3.12+)

Senior Python interviews go beyond syntax into how the object model actually resolves attributes, and how to pick the right concurrency primitive for a given workload.

Metaclasses

A metaclass (subclassing type) intercepts class creation itself via __new__/__init__ -- used by ORMs like Django to auto-register model fields; __init_subclass__ or a class decorator is usually the simpler modern alternative.

MRO & C3 Linearization

CPython resolves multiple inheritance via C3 linearization (inspectable as Cls.__mro__), guaranteeing a consistent, monotonic order even in diamond hierarchies; super() follows the MRO, not just the immediate parent.

threading vs multiprocessing vs asyncio

threading suits I/O-bound work sharing memory (GIL releases during I/O); multiprocessing bypasses the GIL entirely for CPU-bound parallelism at the cost of IPC/pickling; asyncio gives single-thread cooperative concurrency for huge I/O fan-out.

Typing & Python 3.12+

PEP 695 introduces native generic syntax (`class Stack[T]:`), Protocol enables structural typing without inheritance, and type hints remain unenforced at runtime unless validated by mypy/pyright or pydantic.

Asyncio Single-Threaded Event Loop Cycle

How cooperative coroutines interleave on one OS thread without ever needing multiple cores.

1
Coroutine scheduled
asyncio.create_task(coro) wraps the coroutine in a Task and places it on the event loop's ready queue.
2
Task runs until first await
The loop calls into the coroutine; it executes synchronously until it hits an await on something not yet ready (e.g. a socket read).
3
Control yields back to the loop
The awaited operation registers a callback with the OS selector (epoll/kqueue), and the coroutine's frame suspends, returning control to the loop.
4
Loop polls for I/O readiness
The loop calls the selector's poll/select with zero busy-waiting, finding which registered sockets or timers have become ready.
5
Callback resumes the waiting task
When data arrives, the loop resumes the corresponding Task from exactly where it suspended, continuing execution after the await point.
Concurrent I/O with asyncio.gather vs Sequential Blocking Calls
python
import asyncio
  import time
  
  async def fetch_page(session_id: int, delay: float) -> str:
      print(f"[{session_id}] request sent")
      await asyncio.sleep(delay)  # simulates non-blocking network I/O
      print(f"[{session_id}] response received")
      return f"page-{session_id}"
  
  async def main():
      start = time.perf_counter()
      results = await asyncio.gather(
          fetch_page(1, 1.0),
          fetch_page(2, 1.0),
          fetch_page(3, 1.0),
      )
      print(results, f"elapsed={time.perf_counter() - start:.2f}s")  # ~1.0s, not 3.0s
  
  asyncio.run(main())
Why it matters: All three coroutines start immediately and suspend at await asyncio.sleep(), letting the single event loop interleave them. Total wall-clock time is roughly 1 second (the slowest task) instead of 3 seconds, because the tasks overlap cooperatively on one thread rather than running sequentially.
Interviewer Insights & Pro Tips
  • Use multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor for CPU-bound number crunching; threads won't help because of the GIL.
  • Reach for asyncio when you need thousands of concurrent connections with minimal per-task overhead; reach for threads when integrating with blocking third-party libraries you can't rewrite.
Red Flags & Common Pitfalls
  • Calling a blocking function (time.sleep, a synchronous requests.get) inside an async coroutine freezes the entire event loop for every other task.
  • Assuming overriding __eq__ alone is enough -- Python sets __hash__ to None automatically unless you also define it, making instances unhashable.
Real-World Example

Cutting API Latency by Replacing Threaded Polling with Asyncio at a Fintech Startup

A payments startup's Python backend polled six third-party bank APIs sequentially inside a Flask endpoint using a thread pool, and p99 latency on the /reconcile endpoint had crept past 4 seconds as the number of integrated banks grew. The on-call engineer was asked to bring it back under 1 second without adding new infrastructure.

  • 1Profiled the endpoint with cProfile and confirmed nearly all wall-clock time was spent waiting on outbound HTTPS calls, not CPU work, ruling out multiprocessing.
  • 2Rewrote the six bank API calls as async coroutines using an async HTTP client and fired them concurrently with asyncio.gather instead of the existing thread pool.
  • 3Adapted the Flask view to run as an async view so the event loop could actually overlap the six requests on one thread.
  • 4Added a per-call timeout with asyncio.wait_for so one slow bank couldn't stall the entire batch, falling back to cached data for stragglers.
  • 5Load-tested with concurrent traffic to confirm the change held up before rolling it out behind a feature flag.
Outcome: p99 latency on /reconcile dropped from 4.1s to 640ms, and the service handled roughly 3x the concurrent request volume on the same instance size.
Real-World Interview Questions

Top Must-Know Interview Questions & Model Answers

GIL & ConcurrencyMust-Know

Q1: What is the Global Interpreter Lock (GIL) and why does CPython have one?

Executive Answer:The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time, simplifying reference-count safety at the cost of true parallel multithreading for CPU-bound code.
Deep Dive Analysis:
  • CPython's reference counting isn't atomic across threads by default; the GIL avoids costly fine-grained locking on every object's refcount.
  • The GIL is released periodically (based on sys.getswitchinterval, default ~5ms) and explicitly around blocking C-level I/O calls.
  • It is a CPython implementation detail, not a language requirement -- Jython, IronPython, and the experimental free-threaded CPython 3.13 build do not have it.
Interviewer Takeaway: For CPU-bound parallelism in CPython, bypass the GIL with multiprocessing or native extensions that release it, rather than threading.
Memory ManagementMust-Know

Q2: How does CPython's reference counting garbage collection work, and where does it fall short?

Executive Answer:Every PyObject carries an ob_refcnt field incremented on each new reference and decremented when a reference goes out of scope; when it hits zero the object is deallocated immediately.
Deep Dive Analysis:
  • Refcounting gives deterministic, immediate cleanup (no stop-the-world pauses) but cannot free objects that reference each other in a cycle, since their counts never reach zero.
  • sys.getrefcount(obj) reports the count plus one, because passing obj as an argument creates a temporary extra reference.
  • CPython supplements refcounting with a generational cyclic garbage collector (the gc module) to catch unreachable reference cycles.
Interviewer Takeaway: Reference counting handles the common case instantly; the generational GC is a periodic safety net purely for cycles.
Memory ManagementHard

Q3: Explain Python's generational garbage collector and its three generations.

Executive Answer:The cyclic GC groups objects into three generations (0, 1, 2) based on how long they survive, and collects younger generations far more often than older ones under the assumption that most objects die young.
Deep Dive Analysis:
  • New objects start in generation 0; if they survive a generation-0 collection they're promoted to generation 1, and then generation 2.
  • Default thresholds (700, 10, 10) mean generation 0 is scanned after 700 net allocations, generation 1 after 10 generation-0 collections, and generation 2 after 10 generation-1 collections.
  • The collector only needs to examine 'container' objects capable of holding references (lists, dicts, instances) since only they can form cycles.
Interviewer Takeaway: This is the classic 'most garbage dies young' generational hypothesis applied to memory management -- scan young objects most frequently.
GIL & ConcurrencyHard

Q4: What actually happens to the GIL when a thread performs blocking I/O?

Executive Answer:Before entering a blocking system call (socket recv, file read), CPython's C implementation explicitly releases the GIL, letting other Python threads run, and reacquires it once the call returns.
Deep Dive Analysis:
  • This is why threading is genuinely useful for I/O-bound workloads in Python despite the GIL -- the lock isn't held during the wait.
  • Pure CPU-bound Python bytecode loops only yield the GIL at the configured switch interval, not on every instruction.
  • C extensions like NumPy can release the GIL around their internal computation too, via Py_BEGIN_ALLOW_THREADS.
Interviewer Takeaway: Whether threading helps depends on whether the hot path is I/O (GIL released) or pure Python bytecode (GIL held).
ConcurrencyMust-Know

Q5: Compare threading, multiprocessing, and asyncio for concurrent Python workloads.

Executive Answer:threading suits I/O-bound tasks needing shared memory; multiprocessing suits CPU-bound tasks needing true parallel cores at the cost of IPC/pickling overhead; asyncio suits massively concurrent I/O with minimal per-task overhead.
Deep Dive Analysis:
  • threading: cheap context switches and a shared address space, but the GIL serializes bytecode execution so there's no CPU parallelism.
  • multiprocessing: separate interpreters and memory spaces bypass the GIL entirely, but data must be pickled/copied between processes (or shared via multiprocessing.shared_memory).
  • asyncio: a single OS thread with cooperative scheduling via an event loop; scales to tens of thousands of concurrent sockets, but one blocking call stalls everything.
Interviewer Takeaway: Match the tool to the bottleneck: I/O-bound + simplicity -> threading; CPU-bound -> multiprocessing; I/O-bound + huge concurrency -> asyncio.
ConcurrencyHard

Q6: Walk through what happens inside the asyncio event loop when you await asyncio.sleep().

Executive Answer:await suspends the current coroutine, registers a wake-up callback (a timer, in this case) with the loop, and hands control back to the loop, which runs other ready tasks until the callback fires.
Deep Dive Analysis:
  • asyncio.sleep schedules a call_later callback on the loop instead of blocking the thread.
  • The event loop's core is effectively a select()/epoll()/kqueue() call plus a heap of scheduled timer callbacks.
  • When the coroutine resumes, execution continues exactly after the await statement with local state intact, because a coroutine is a suspendable generator-like frame.
Interviewer Takeaway: Coroutines are suspended generator frames; the loop is just a scheduler polling for readiness and firing callbacks.
ConcurrencyMedium

Q7: Why is multiprocessing overhead often higher than expected, and how do you reduce it?

Executive Answer:Data passed between processes must be pickled and sent through a pipe or shared-memory segment, and each worker starts a whole new Python interpreter, which dominates cost for small tasks.
Deep Dive Analysis:
  • Use a Pool with chunksize tuning to amortize IPC overhead across many small work items.
  • Prefer multiprocessing.shared_memory or memory-mapped arrays for large datasets instead of pickling them per call.
  • On Linux the default 'fork' start method copies the parent's memory via copy-on-write, which is much cheaper than 'spawn' (default on Windows/macOS), which re-imports the module from scratch.
Interviewer Takeaway: Multiprocessing wins only when per-task compute time meaningfully exceeds IPC and process-startup overhead.
Data StructuresMust-Know

Q8: What is the difference between a mutable and an immutable type in Python, and why does it matter for function defaults and dict keys?

Executive Answer:Immutable objects (int, str, tuple, frozenset) cannot be changed after creation and are safe to hash and share; mutable objects (list, dict, set) can change in place, so they cannot be dict keys and are dangerous as shared default state.
Deep Dive Analysis:
  • Hashability requires an object whose value (and hash) never changes during its lifetime -- this is why lists can't be dict keys but tuples of immutables can.
  • Mutable default arguments are evaluated once at function definition time and persist across calls, a classic interview trap.
  • Immutable objects enable safe sharing across threads without defensive copying, simplifying reasoning about concurrent code.
Interviewer Takeaway: If it needs to be a dict key or set member, it must be immutable and hashable.
Data StructuresHard

Q9: Explain how Python's dict achieves average O(1) lookup internally.

Executive Answer:A dict is an open-addressing hash table: each key's hash determines a slot index, and collisions are resolved via a perturbation-based probing sequence rather than chaining.
Deep Dive Analysis:
  • Since Python 3.6/3.7, dicts maintain insertion order using a compact layout: a sparse index array pointing into a dense array of (hash, key, value) entries.
  • The table resizes (growing roughly 4x when small, 2x when larger) once it's about two-thirds full, to keep probe sequences short.
  • Key lookup computes hash(key), probes the sparse table for a matching slot (checking hash equality then __eq__), giving amortized O(1) average time but O(n) worst case under pathological collisions.
Interviewer Takeaway: Insertion-ordering is a side effect of the compact dict implementation, not a separate ordered structure.
Data StructuresMedium

Q10: How does a Python list grow, and what is the time complexity of append vs insert(0, x)?

Executive Answer:Lists are dynamic arrays that over-allocate extra capacity on growth, making append() amortized O(1); insert(0, x) is O(n) because every existing element must shift right.
Deep Dive Analysis:
  • CPython's list growth pattern roughly follows newsize + (newsize >> 3) + 6, over-allocating so not every append triggers a reallocation.
  • Because a list is contiguous memory, indexing lst[i] is O(1), but inserting or removing anywhere except the end requires shifting the remaining elements.
  • collections.deque is a doubly linked list of fixed-size blocks giving O(1) appends/pops from both ends, making it the right structure for queues.
Interviewer Takeaway: Use list for O(1) end-append and O(1) random access; use deque when you also need O(1) operations at the front.
Data StructuresMedium

Q11: How is a Python set implemented, and how does it differ from a dict internally?

Executive Answer:A set uses essentially the same open-addressing hash table machinery as a dict, but stores only keys (no associated values), giving average O(1) add/contains/discard.
Deep Dive Analysis:
  • set operations like union, intersection, and difference are implemented in C and are much faster than the equivalent manual loop-and-compare in Python.
  • frozenset is the immutable, hashable counterpart, usable as a dict key or set member.
  • Set membership testing (`x in my_set`) is O(1) average vs O(n) for `x in my_list`, a common performance-tuning interview point.
Interviewer Takeaway: Reach for a set (or frozenset) whenever you only need membership testing, not ordering or duplicates.
Data StructuresMedium

Q12: What is the difference between a shallow copy and a deep copy?

Executive Answer:A shallow copy (list(x), x.copy(), copy.copy(x)) creates a new outer container but reuses references to the same nested objects; a deep copy (copy.deepcopy(x)) recursively copies every nested object too.
Deep Dive Analysis:
  • Mutating a nested mutable object (e.g. a list inside a list) through a shallow copy also affects the original, which surprises many candidates.
  • copy.deepcopy handles cyclic references safely via a memo dictionary that tracks already-copied objects.
  • Slicing (lst[:]) and dict(d) both produce shallow copies, not deep ones.
Interviewer Takeaway: Ask 'does this container hold other mutable containers?' before assuming a shallow copy is safe enough.
Decorators & GeneratorsMust-Know

Q13: How do Python decorators work under the hood?

Executive Answer:A decorator is just a callable that takes a function and returns a (usually wrapping) callable; `@decorator` above a def is pure syntax sugar for `func = decorator(func)`.
Deep Dive Analysis:
  • A parameterized decorator (`@retry(times=3)`) is really a decorator factory: an outer function that takes arguments and returns the actual decorator.
  • functools.wraps(func) copies __name__, __doc__, and __wrapped__ onto the wrapper so introspection, help(), and stack traces stay meaningful.
  • Decorators can stack; they apply bottom-up, so `@a` `@b` above def f() is equivalent to f = a(b(f)).
Interviewer Takeaway: Always use functools.wraps in a decorator you write, or introspection/pickling on the decorated function silently breaks.
Decorators & GeneratorsMust-Know

Q14: What is the difference between a generator and a regular function returning a list?

Executive Answer:A generator function (using yield) produces values lazily one at a time and suspends its frame between yields, using O(1) memory regardless of sequence length, while a function returning a list must materialize every element up front.
Deep Dive Analysis:
  • Calling a generator function returns a generator object immediately without running any code; execution only proceeds on each call to next() or a for-loop iteration.
  • State (local variables, instruction pointer) is preserved on the generator's frame between yields, unlike a normal function whose frame is discarded on return.
  • yield from delegates iteration to a sub-generator or iterable, forwarding values, exceptions, and the return value transparently.
Interviewer Takeaway: Prefer generators/generator expressions whenever processing a large or unbounded sequence without needing random access.
Decorators & GeneratorsMust-Know

Q15: How does the context manager protocol (the with statement) guarantee cleanup?

Executive Answer:`with obj:` calls obj.__enter__() to set up, binds its return value if `as x` is used, runs the block, and unconditionally calls obj.__exit__(exc_type, exc_val, exc_tb) even if the block raised -- analogous to a try/finally.
Deep Dive Analysis:
  • @contextlib.contextmanager turns a generator function into a context manager: code before yield is __enter__, the yielded value is bound by `as`, and code after yield (in a finally) is __exit__.
  • If __exit__ returns a truthy value, the exception is suppressed; returning None/False lets it propagate -- a subtle footgun.
  • Multiple context managers in one `with a, b:` statement nest right-to-left on exit, mirroring nested `with a:` `with b:` blocks.
Interviewer Takeaway: Context managers exist to guarantee deterministic cleanup regardless of how the block exits.
OOPHard

Q16: What is a metaclass, and when would you actually use one?

Executive Answer:A metaclass is the 'class of a class' (by default type) that controls how a class object itself is constructed; a custom metaclass hooks into class creation, e.g. to auto-register subclasses or validate class attributes at definition time.
Deep Dive Analysis:
  • `class Foo(metaclass=Meta):` runs Meta.__new__/Meta.__init__ with the class name, bases, and namespace dict, letting you inspect or rewrite the class before it's created.
  • Django's ORM and SQLAlchemy's declarative base both use metaclasses to turn class-level attribute declarations into database column mappings automatically.
  • Most 'I need a metaclass' problems are solvable more simply with __init_subclass__ (a hook for subclass creation) or a class decorator, the modern, less invasive alternative.
Interviewer Takeaway: Reach for __init_subclass__ or a class decorator first; a metaclass is the last resort when you truly need to alter class construction itself.
OOPHard

Q17: Explain Method Resolution Order (MRO) and C3 linearization in multiple inheritance.

Executive Answer:MRO defines the order Python searches base classes for an attribute; CPython uses the C3 linearization algorithm to produce a single consistent, monotonic order even with diamond-shaped inheritance hierarchies.
Deep Dive Analysis:
  • You can inspect it directly via ClassName.__mro__ or ClassName.mro().
  • C3 guarantees a subclass always precedes its parents, and respects each class's locally declared base order consistently -- something a naive depth-first search fails to guarantee.
  • super() uses the MRO, not the immediate parent class, which is why cooperative multiple inheritance (every class calling super().__init__()) correctly chains through a diamond hierarchy exactly once per class.
Interviewer Takeaway: super() means 'next in the MRO', not 'my parent class' -- critical for reasoning correctly about diamond inheritance.
OOPMedium

Q18: What is the contract between __eq__ and __hash__, and what breaks if you violate it?

Executive Answer:If two objects compare equal via __eq__, they must return the same __hash__ value, because hash-based containers (dict, set) rely on this to locate matching keys; overriding __eq__ without __hash__ makes the class unhashable by default.
Deep Dive Analysis:
  • Defining __eq__ on a class sets __hash__ to None automatically unless you also define __hash__ explicitly, immediately breaking use as a dict key or set member.
  • Violating the contract (equal objects, different hashes) causes silent bugs: a set may contain 'duplicate' equal entries because they land in different buckets.
  • dataclasses handle this automatically -- @dataclass(frozen=True) generates a consistent __eq__/__hash__ pair for you.
Interviewer Takeaway: Whenever you override __eq__, immediately decide (and likely implement) __hash__ consistently, or explicitly set __hash__ = None.
OOPMedium

Q19: What are Python's key dunder methods for making a custom class behave like a built-in container?

Executive Answer:Implementing __len__, __getitem__, __setitem__, __contains__, and __iter__ lets a class support len(), indexing, `in`, and for-loops like a native list or dict.
Deep Dive Analysis:
  • __iter__ should return an iterator (an object with __next__); a generator function naturally satisfies this.
  • __repr__ should return an unambiguous, ideally eval-able representation for debugging; __str__ is for user-friendly display and falls back to __repr__ if undefined.
  • __enter__/__exit__ make a class usable as a context manager; __call__ makes instances callable like functions.
Interviewer Takeaway: Dunder methods are how Python implements duck typing for its own syntax (len, for, in, with, ()) -- implement the ones relevant to your object's behavior.
TypingMedium

Q20: How do Python type hints (the typing module) work at runtime, and what is a Protocol?

Executive Answer:Type hints are not enforced by the interpreter at runtime -- they're metadata checked by static tools like mypy/pyright; a Protocol defines structural typing where any object with matching methods satisfies it, without explicit inheritance.
Deep Dive Analysis:
  • TypeVar and Generic let you write classes/functions parameterized over a type, e.g. class Stack(Generic[T]); PEP 695 (Python 3.12) introduces native syntax -- class Stack[T]: ... and def first[T](items: list[T]) -> T: ... -- without importing TypeVar.
  • Protocol (from typing) enables 'if it walks like a duck': a class satisfies class Sized(Protocol): def __len__(self) -> int: ... just by having a compatible __len__, with no inheritance required.
  • typing.TYPE_CHECKING guards imports that are only needed for type hints, avoiding runtime circular-import costs.
Interviewer Takeaway: Type hints buy static safety and editor tooling; they impose zero runtime behavior unless explicitly validated (e.g. with pydantic).
Modern PythonMedium

Q21: What notable changes did Python 3.12 introduce that interviewers now expect you to know?

Executive Answer:Python 3.12 shipped PEP 695 generic syntax, a more efficient per-object memory layout, improved f-string parsing (PEP 701), and faster comprehensions, while 3.13 introduced an experimental free-threaded (no-GIL) build.
Deep Dive Analysis:
  • PEP 695: `type Alias = list[int]` and `class Box[T]: ...` / `def f[T](x: T) -> T: ...` replace verbose TypeVar/Generic boilerplate.
  • PEP 701 relaxes f-string grammar: you can now reuse the same quote character inside the expression part and nest f-strings freely.
  • PEP 703 (targeted for 3.13+) is the effort to make the GIL optional (a --disable-gil build), a major potential shift for CPU-bound multithreading -- still experimental and not the default.
Interviewer Takeaway: Interviewers use 'what's new in 3.12/3.13' as a proxy for whether you actively keep up with the language, not just memorized trivia.
ConcurrencyHard

Q22: Why can calling a blocking function inside an async coroutine be catastrophic for performance?

Executive Answer:asyncio runs on a single thread; any call that blocks that thread (time.sleep, a synchronous requests.get, heavy CPU computation) freezes the entire event loop, stalling every other concurrently scheduled task.
Deep Dive Analysis:
  • The fix is to use the async-native equivalent (asyncio.sleep, an async HTTP client like httpx/aiohttp) or offload blocking work to a thread/process pool via loop.run_in_executor.
  • CPU-bound work inside a coroutine is just as damaging as blocking I/O -- it too monopolizes the single event-loop thread.
  • asyncio's debug mode (PYTHONASYNCIODEBUG=1) warns when a callback takes too long, helping surface accidental blocking calls.
Interviewer Takeaway: In async code, 'blocking' isn't just about I/O -- anything occupying the single loop thread for a nontrivial time is a bug.
Memory ManagementMedium

Q23: Explain __slots__ and when it's worth using.

Executive Answer:__slots__ tells Python to allocate fixed-size storage for a fixed set of attributes instead of giving each instance a per-object __dict__, cutting per-instance memory and slightly speeding attribute access.
Deep Dive Analysis:
  • Without __slots__, every instance carries a __dict__ (a full hash table) even if it only stores 2-3 attributes -- significant overhead when creating millions of small objects.
  • Classes using __slots__ cannot have arbitrary new attributes added at runtime, and multiple inheritance with slots across unrelated classes gets tricky (layout conflicts).
  • @dataclass(slots=True) (Python 3.10+) generates a slotted dataclass automatically, combining ergonomics with the memory win.
Interviewer Takeaway: Use __slots__ for high-volume, fixed-shape objects (e.g. graph nodes processed by the million) where memory footprint matters.
OOPHard

Q24: What is the descriptor protocol, and how do @property and instance methods rely on it?

Executive Answer:A descriptor is any object implementing __get__ (and optionally __set__/__delete__) placed as a class attribute; Python's attribute lookup machinery calls these methods instead of returning the raw value, which is exactly how @property and bound methods work.
Deep Dive Analysis:
  • @property is sugar for a data descriptor: property(fget, fset, fdel) implements __get__/__set__ to intercept attribute access with custom logic.
  • Functions are themselves non-data descriptors -- accessing instance.method invokes the function's __get__, which returns a bound method with self pre-filled.
  • Data descriptors (defining __set__) take priority over instance __dict__ entries during attribute lookup; non-data descriptors (only __get__) do not.
Interviewer Takeaway: Almost every 'magic' attribute-access behavior in Python (properties, bound methods, functools.cached_property) is implemented via the descriptor protocol.
Memory ManagementHard

Q25: How would you detect and prevent a memory leak in a long-running Python service?

Executive Answer:Use tracemalloc or objgraph to snapshot and diff live object counts over time, look for growing reference cycles, unbounded caches, or listeners that are never unregistered, and fix by breaking cycles with weakref or bounding caches.
Deep Dive Analysis:
  • Objects participating in a reference cycle and defining __del__ historically could not be collected at all in Python before 3.4; modern CPython (PEP 442) safely collects them, but __del__ order is still undefined, encouraging weakref-based cleanup instead.
  • weakref.ref / weakref.WeakValueDictionary let caches or observer registries hold non-owning references so they don't keep objects alive.
  • tracemalloc.take_snapshot() plus snapshot.compare_to() pinpoints exactly which allocation call sites are growing between two points in time.
Interviewer Takeaway: Most 'memory leaks' in Python are accidental object retention (caches, listeners, closures capturing large scopes), not classic C-style leaks.
Core LanguageMust-Know

Q26: What's the difference between == and is, and where does this commonly trip people up?

Executive Answer:== calls __eq__ to compare value equality; is compares object identity (the same memory address / id()); they diverge whenever two distinct objects can be equal in value, which is the common case for anything beyond small cached integers and interned strings.
Deep Dive Analysis:
  • CPython caches small integers (-5 to 256) and some string literals, so `a is b` can accidentally appear to work for small ints in casual testing but fails for larger numbers or dynamically built strings.
  • The only correct, portable use of is is comparing against singletons: x is None, x is True, x is NotImplemented.
  • Using is to compare strings/numbers is an implementation-detail-dependent bug waiting to surface in a different CPython build or version.
Interviewer Takeaway: Default to == for value comparison; reserve is strictly for identity/singleton checks like is None.
Core LanguageMedium

Q27: Why is a bare except: clause considered bad practice?

Executive Answer:A bare except: catches everything including SystemExit, KeyboardInterrupt, and GeneratorExit, which can prevent the program from shutting down cleanly or swallow Ctrl+C entirely.
Deep Dive Analysis:
  • Prefer except Exception: at minimum, which excludes BaseException-only signals like SystemExit/KeyboardInterrupt.
  • Catching overly broad exceptions also hides real bugs (typos, wrong types) that should surface immediately during development.
  • Always log or re-raise (a bare `raise` preserves the original traceback) rather than silently passing in an except block.
Interviewer Takeaway: Catch the narrowest exception type you can meaningfully handle; broad catches should be the rare exception, not the default habit.
Data StructuresMedium

Q28: What happens when you modify a list while iterating over it, and how do you avoid the bug?

Executive Answer:A list iterator tracks a numeric index into the underlying array; removing or inserting elements during iteration shifts subsequent elements into already-visited or skipped positions, silently causing elements to be skipped or processed twice.
Deep Dive Analysis:
  • Iterating over list(original) (a copy) or reversed(original) while mutating the original avoids index-shifting bugs.
  • For filtering, prefer building a new list via a comprehension ([x for x in items if keep(x)]) rather than removing matches in place.
  • The same hazard applies to dict/set: RuntimeError: dictionary changed size during iteration is raised if you add/remove keys while iterating a dict directly.
Interviewer Takeaway: Never mutate a collection's size while iterating it directly -- iterate a copy, or build a new collection instead.
Common Mistakes

Mistakes That Sink Otherwise Strong Candidates

Using a mutable object (list, dict) as a default function argument.

Why it happens: Default argument expressions are evaluated once at function definition time, not on every call, which is unintuitive coming from most other languages.

The fix: Default to None and lazily create the mutable object inside the function body on first use.

Reaching for threading to speed up CPU-bound number crunching.

Why it happens: Threads look like the obvious concurrency primitive, but the GIL serializes bytecode execution so multiple threads don't get parallel CPU time for pure Python code.

The fix: Use multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor for CPU-bound work, or vectorize with a library that releases the GIL internally.

Blocking the asyncio event loop with a synchronous call inside a coroutine.

Why it happens: It's easy to forget that async code shares a single OS thread, so any function that doesn't yield control stalls every other task.

The fix: Swap in async-native equivalents (asyncio.sleep, an async HTTP client) or push blocking calls to a thread pool via loop.run_in_executor.

Comparing values with is instead of ==.

Why it happens: Small integer and string interning caching makes is appear to work correctly in quick manual tests, hiding the bug until it hits larger values in production.

The fix: Reserve is exclusively for identity/singleton checks (is None); always use == for value comparison.

Overriding __eq__ without also defining __hash__.

Why it happens: Python automatically sets __hash__ to None when __eq__ is defined, silently making the class unusable as a dict key or set member.

The fix: Define a consistent __hash__ alongside __eq__, or use @dataclass(frozen=True) which generates both correctly.

Assuming a shallow copy is sufficient for nested mutable data.

Why it happens: Names like list.copy() or x[:] sound like a full copy, and the bug only manifests once a nested object is mutated through one reference.

The fix: Use copy.deepcopy() whenever the structure contains nested mutable containers you need to fully isolate.

Iterating over a list or dict while adding or removing its elements.

Why it happens: It looks harmless syntactically, and the bug (skipped elements or a RuntimeError) doesn't always surface with small test inputs.

The fix: Iterate over a copy (list(original)) or build a new filtered collection via a comprehension instead of mutating in place.

Catching exceptions with a bare except: clause.

Why it happens: It feels like a safe catch-all during quick debugging, but it also swallows SystemExit and KeyboardInterrupt and hides real bugs.

The fix: Catch the narrowest exception class you can meaningfully handle, and re-raise or log anything unexpected.

Forgetting that generators are single-use and get exhausted after one full iteration.

Why it happens: A generator looks like a list from the outside, so reusing the same generator object in a second for-loop feels natural.

The fix: Materialize the values into a list if you need to iterate more than once, or re-invoke the generator function to get a fresh generator.

Cheat Sheet

Quick-Reference Cheat Sheet

Time Complexity of Core Data Structures
list.append(x)O(1) amortized
list.insert(0, x)O(n) -- shifts every element
list[i] indexingO(1)
dict / set get, add, containsO(1) average, O(n) worst case
x in listO(n) linear scan
x in set / dictO(1) average
deque.append / appendleftO(1) on both ends
sorted(list)O(n log n) -- Timsort
GIL & Concurrency Facts
GIL scopeOne CPython process; released around blocking I/O and every ~5ms switch interval
threading best forI/O-bound tasks (network, disk, blocking calls)
multiprocessing best forCPU-bound tasks; bypasses the GIL via separate processes
asyncio best forMassive concurrent I/O on a single thread
Free-threaded CPythonPEP 703 -- experimental --disable-gil build, 3.13+
Default start methodfork on Linux, spawn on Windows/macOS
Mutable vs Immutable Types
Immutableint, float, bool, str, tuple, frozenset, bytes
Mutablelist, dict, set, bytearray, most custom classes
Hashable requirementValue (and hash) must never change during its lifetime
Default argument gotchaMutable defaults are created once at def time -- use a None sentinel
Safe dict key typestuple of immutables, frozenset, str, int
Decorators, Generators & Context Managers
Preserve metadata@functools.wraps(func) inside every decorator
Lazy sequenceyield inside a function makes it a generator
Delegate to sub-generatoryield from sub_gen()
Generator to context manager@contextlib.contextmanager
Suppress exception in __exit__return True (use sparingly)
Memory-efficient comprehension(x for x in items) generator expr vs [x for x in items] list
Typing & Python 3.12+ Features
Generic class (3.12+)class Stack[T]: ...
Generic function (3.12+)def first[T](items: list[T]) -> T: ...
Type alias (3.12+)type IntList = list[int]
Structural typingclass Sized(Protocol): def __len__(self) -> int: ...
Runtime enforcementType hints are NOT checked at runtime -- use mypy/pyright or pydantic
f-string upgrade (3.12)PEP 701 -- reuse same quotes, nest f-strings freely
Assessment Integration

Recommended Practice Quizzes on QuizCluster

Test your retention and prepare for timed live coding and MCQ technical screening rounds:

Frequently Asked Questions

Do I need to know the GIL in depth for a junior Python interview?

Yes, at a conceptual level -- expect at least one question distinguishing threading, multiprocessing, and asyncio and explaining why the GIL affects each differently. Deep internals like switch intervals and PEP 703 are more common at senior/staff level.

Is Python 2 knowledge still relevant?

Rarely. Interviews in 2026 assume Python 3.10+ idioms -- focus on match statements, the walrus operator, f-strings, and 3.12's generic syntax rather than Python 2/3 compatibility tricks.

Should I memorize exact CPython source constants like dict resize thresholds?

No -- interviewers care that you understand the underlying model (open addressing, amortized growth, generational GC) and can reason about performance trade-offs, not that you've memorized exact numeric constants.

Explore Other Preparation Guides

Software Engineering
How to Prepare for SDE Interview: Complete 2026 Roadmap
16 min readRead →
Java Ecosystem
How to Prepare for Java Developer Interview: Core to Spring Boot & JVM
18 min readRead →
Microservices & Distributed Systems
How to Prepare for Microservices Developer Interview: Distributed Architecture & Cloud
17 min readRead →
System Design
System Design Interview Guide: Complete 2026 Roadmap
21 min readRead →
Databases
SQL Interview Questions & Preparation Guide: Beginner to Advanced
17 min readRead →
Frontend Engineering
React Interview Preparation: React 19 & Next.js Guide
17 min readRead →
Cloud & DevOps
Kubernetes Interview Guide: Architecture, Pods, Networking & Troubleshooting
17 min readRead →
Cloud & DevOps
AWS Solutions Architect Interview Guide: Real Architecture Scenarios
17 min readRead →
Databases
Database System Design: SQL vs NoSQL, Sharding, Replication & Indexing
19 min readRead →
Microservices & Distributed Systems
Kafka Interview Guide: Architecture, Consumers, Partitions & Exactly-Once Semantics
17 min readRead →
Backend Engineering
REST API Design Interview Guide: Authentication, Pagination, Versioning & Rate Limiting
15 min readRead →
Cloud & DevOps
Docker Interview Guide: Images, Containers, Networking & Production Debugging
15 min readRead →
Programming Languages
JavaScript & TypeScript Interview Guide: From Closures to the Event Loop
17 min readRead →
Backend Engineering
Node.js Backend Interview Guide: Event Loop, Streams, APIs & Scaling
17 min readRead →
Databases
Redis System Design Guide: Caching, Eviction, Persistence & Distributed Locks
17 min readRead →
Software Engineering
Concurrency Interview Guide: Threads, Locks, Race Conditions & Deadlocks
17 min readRead →
Software Engineering
Dynamic Programming Patterns: How to Recognize and Solve DP Problems
17 min readRead →