Python developer interview preparation guide for middle developers
Article

Top 30 Python Developer Interview Questions and Answers (2026)

What This Guide Covers — and How to Use It

Most Python interview guides for "middle developers" still ask beginner questions: what is a list comprehension, how do you open a file, what does len() return. Those are screener questions. If you are applying for a mid-level role, the conversation starts after those.

This guide covers the 30 questions that actually decide outcomes at the middle-developer level — questions that probe whether you understand Python beyond its syntax. The selection reflects real interview patterns from product companies, fintech, infrastructure, and ML engineering teams. Each question is included because it either appears with high frequency across company types, reveals meaningful gaps when answered poorly, or tests a concept that directly causes production bugs. For each you get:

  • What the interviewer is actually testing — the concept underneath the question
  • What a mediocre answer sounds like — so you recognize it and do better
  • What an excellent answer includes — with working, annotated code
  • Follow-up traps — the second question that trips people up after the first answer

The 30 questions are grouped into six thematic sections that build on each other: Python internals and the data model → functions, closures, and decorators → object-oriented depth → concurrency and performance → data structures and the type system → tricky language behavior and modern Python. Work through them in order the first time; the later sections assume the earlier ones.

All 30 questions at a glance: how the GIL affects threading, Python's reference-counting memory model and the cyclic garbage collector, the dunder method protocol, descriptors (how property actually works), the is vs == interning trap, LEGB scope rules, the mutable default argument bug and why it exists, *args/**kwargs and parameter ordering rules, decorators with and without arguments, generators vs iterators, MRO and C3 linearization, __new__ vs __init__, __slots__, classmethods and staticmethods, dataclasses vs namedtuples, threading vs multiprocessing vs asyncio, the asyncio event loop and what await actually does, asyncio.gather vs asyncio.wait vs TaskGroup, lru_cache and its pitfalls, Python profiling tools, dict internals, list vs deque vs array, shallow vs deep copy, type hints and Protocol vs ABC, metaclasses, cell objects and closures, context managers both ways, raise from, the walrus operator, and structural pattern matching.

Python Internals & the Data Model (Q1–5)

Q1. What is the GIL (Global Interpreter Lock) and how does it affect multi-threaded Python programs?

What the interviewer is testing: Whether you understand why Python threads do not give you parallelism for CPU-bound code, and whether you know the correct tool for each scenario.

The mediocre answer: "The GIL prevents multiple threads from running Python code at the same time." True, but incomplete. It tells the interviewer you have read about it, not that you understand its implications.

The complete answer: CPython (the reference implementation) has a mutex called the GIL that must be held by any thread executing Python bytecode. At any instant, only one thread executes Python code — even on a multicore machine. The GIL exists because CPython's memory management (reference counting) is not thread-safe; without it, concurrent reference count updates would corrupt memory.

The GIL is released in two situations that make threads useful anyway:

  • I/O operations: When a thread calls read(), write(), recv(), or any blocking syscall, the GIL is released while waiting. Other threads run. This is why threading is appropriate for I/O-bound programs — web scrapers, HTTP servers, database-heavy apps.
  • C extensions that release it explicitly: NumPy, OpenCV, and many C extensions release the GIL during computation. Two NumPy operations in separate threads genuinely execute in parallel on separate cores.
# Threading is fine for I/O-bound work
import threading
import requests

def fetch(url):
    return requests.get(url).status_code  # GIL released during network wait

urls = ['https://example.com'] * 10
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads: t.start()
for t in threads: t.join()
# Actual wall-clock speedup: ~8-10x on 10 URLs, because waiting is parallelized

For CPU-bound work, use multiprocessing: Each process has its own interpreter and GIL. True parallelism. The cost is IPC (inter-process communication) overhead and memory duplication.

from multiprocessing import Pool

def cpu_heavy(n):
    return sum(i * i for i in range(n))

with Pool(processes=4) as pool:
    results = pool.map(cpu_heavy, [10_000_000] * 4)
    # Four cores fully utilized — GIL is irrelevant here

Follow-up trap: "What about Python 3.13's no-GIL mode?" Python 3.13 introduced an experimental free-threaded build (--disable-gil). Thread safety is pushed to individual data structures using fine-grained locking. As of 2026 it is still experimental and most third-party C extensions are not safe without the GIL. For production, multiprocessing and asyncio remain the standard tools for parallelism and concurrency respectively.

Q2. How does Python's memory management work? Explain reference counting and the cyclic garbage collector.

What the interviewer is testing: Do you understand why memory bugs exist in Python, how to avoid them, and when to reach for gc or weakref?

Primary mechanism — reference counting: Every object has a reference count. When you assign an object to a variable, the count increments. When the variable goes out of scope or is reassigned, the count decrements. When the count reaches zero, the object is immediately deallocated.

import sys

x = [1, 2, 3]
print(sys.getrefcount(x))  # 2 (one for x, one for the getrefcount argument)

y = x  # count becomes 3
del y  # count back to 2
del x  # count reaches 0 → immediately freed

The problem — reference cycles: Reference counting alone cannot free objects that reference each other. A parent object pointing to a child that points back to the parent creates a cycle; neither count ever reaches zero.

# Classic cycle: list containing itself
a = []
a.append(a)  # a.__refcount__ never reaches 0

# Object cycle
class Node:
    def __init__(self):
        self.child = None

parent = Node()
child = Node()
parent.child = child
child.parent = parent  # cycle — reference counting cannot free these
del parent, child
# Still in memory until the cyclic GC runs

The solution — CPython's cyclic garbage collector: The gc module implements a generational mark-and-sweep collector that runs periodically to detect and collect cyclic garbage. Objects are divided into three generations. Young objects are collected frequently; survivors are promoted and collected less often.

Practical implications:

  • For most code: you do not need to manage memory manually. Reference counting handles the vast majority of cases immediately and deterministically. Unlike Java's GC, CPython's reference counting releases memory the moment it is no longer needed — no unpredictable GC pauses.
  • For long-running services: watch for cycles in object graphs (common in tree structures with parent references, doubly-linked lists, and objects that register callbacks). Profile with tracemalloc if RSS memory grows without stopping.
  • For performance-critical code: use __slots__ (Q13) to reduce per-object overhead, and weakref.ref or weakref.WeakValueDictionary to break cycles in caches.

Advanced: disabling the cyclic GC for throughput-critical applications. Instagram's engineering team famously disabled CPython's cyclic garbage collector entirely at startup in their Django application, yielding a 10% CPU throughput improvement. This works only because Instagram's object graph has no reference cycles — all cycles are broken by design. If your application genuinely has no cycles, gc.disable() eliminates GC pauses entirely. Force a manual collection during idle time with gc.collect() if needed. Know your object graph before doing this.

import gc

# Inspect current GC thresholds (default: (700, 10, 10))
# gen0 collected after 700 allocations, gen1 after 10 gen0 collections, etc.
print(gc.get_threshold())  # (700, 10, 10)

# Tune for lower latency: collect more frequently to avoid large pauses
gc.set_threshold(100, 5, 5)

# Or disable entirely if you have no cycles (measure first!)
gc.disable()

# Manually collect during a known idle window
gc.collect()
import weakref

class Node:
    def __init__(self, parent=None):
        self.parent = weakref.ref(parent) if parent else None  # weak ref — doesn't prevent GC

    def get_parent(self):
        return self.parent() if self.parent else None  # call to dereference

Q3. Explain Python's dunder (double-underscore) methods. How do they implement the data model?

What the interviewer is testing: Whether you understand how Python operators and built-in functions are built on top of a consistent protocol, and whether you can use this to design expressive APIs.

The core idea: Every Python operator, built-in function, and language construct calls specific dunder methods on objects. len(x) calls x.__len__(). x + y calls x.__add__(y). for item in x calls iter(x) which calls x.__iter__(). This is the data model — it is how Python achieves operator overloading and protocol-based polymorphism without subclassing.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f'Vector({self.x}, {self.y})'

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):  # called when scalar * vector (not vector * scalar)
        return self.__mul__(scalar)

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self):
        return abs(self) != 0

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)       # Vector(4, 6)
print(3 * v1)        # Vector(9, 12) — uses __rmul__
print(abs(v1))       # 5.0
print(bool(Vector(0, 0)))  # False

Important dunder methods every middle developer should know:

  • __repr__ vs __str__: repr is for developers (unambiguous, ideally eval-able); str is for end users (readable). print(x) calls str(x). The interactive REPL calls repr(x).
  • __eq__ and __hash__: If you define __eq__, Python sets __hash__ to None (making the object unhashable). You must define __hash__ explicitly if you need the object usable as a dict key or in a set.
  • __enter__ / __exit__: Context manager protocol — covered in Q27.
  • __getattr__ vs __getattribute__: __getattr__ is called only when normal lookup fails. __getattribute__ intercepts every attribute access — powerful but easy to create infinite recursion.

Follow-up trap: "What happens to __hash__ when you define __eq__?" Python automatically sets __hash__ = None, making the class unhashable. If you want the object to be usable in sets or as dict keys, you must explicitly define __hash__ alongside __eq__.

Q4. How do Python descriptors work? How do property, classmethod, and staticmethod use them?

What the interviewer is testing: Whether you understand the mechanism behind @property and @classmethod, not just how to use them. A developer who knows only the decorator syntax will give a shallow answer; one who knows the descriptor protocol can reason about surprising attribute access behavior under any circumstance.

A descriptor is an object that defines any of __get__, __set__, or __delete__. When an attribute is defined on a class and that attribute object has one of these methods, Python calls those methods instead of doing a normal attribute lookup.

There are two types: data descriptors define both __get__ and __set__ (or __delete__). Non-data descriptors define only __get__. The difference matters because Python uses a strict lookup priority order for attribute access:

  1. Data descriptor on the class (or any class in the MRO) — wins over everything on the instance
  2. Instance __dict__ — the instance's own attributes
  3. Non-data descriptor on the class — only if the instance has no matching attribute

This explains a common interview question: "why can't I override a @property by setting an instance attribute?" Because property is a data descriptor — it defines both __get__ and __set__ — so it sits above instance __dict__ in the lookup order and intercepts the assignment.

class ValidatedAge:
    """A data descriptor that validates age on assignment."""

    def __set_name__(self, owner, name):
        self.name = f'_{name}'  # private storage attribute name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # accessed on the class itself
        return getattr(obj, self.name, None)

    def __set__(self, obj, value):
        if not isinstance(value, int) or not (0 <= value <= 150):
            raise ValueError(f'Age must be int between 0 and 150, got {value!r}')
        setattr(obj, self.name, value)

class Person:
    age = ValidatedAge()

    def __init__(self, name, age):
        self.name = name
        self.age = age  # calls ValidatedAge.__set__

p = Person('Alice', 30)
print(p.age)  # calls ValidatedAge.__get__ → 30
p.age = -1    # raises ValueError

How property uses this: property is a built-in data descriptor. When you write @property, you are creating an instance of the property descriptor class. Its __get__ calls your getter function; its __set__ calls your setter.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):  # equivalent to: radius = property(fget=this_function)
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError('Radius cannot be negative')
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

How classmethod and staticmethod use this: Both are non-data descriptors. classmethod.__get__ returns a bound method that receives the class as the first argument. staticmethod.__get__ returns the raw function with no automatic argument binding.

Q5. What is the difference between is and ==? When does CPython's interning make this confusing?

What the interviewer is testing: Understanding of identity vs equality, and awareness of a common source of subtle bugs.

== calls __eq__ — it tests value equality. is tests identity — whether two names reference the exact same object in memory (id(a) == id(b)). Use is for singletons (None, True, False). Use == for everything else.

The interning trap: CPython interns (reuses) certain objects for performance. Small integers (-5 to 256) and short strings that look like identifiers are interned by default. This makes is behave like == for these values — but only in CPython, and only within the same scope. This behavior is an implementation detail, not a language guarantee.

# Small integers are interned — CPython implementation detail
a = 256
b = 256
print(a is b)   # True — same cached object
print(a == b)   # True

a = 257
b = 257
print(a is b)   # False in most contexts (not interned)
print(a == b)   # True — the correct comparison

# String interning
s1 = 'hello'
s2 = 'hello'
print(s1 is s2)  # True — interned (looks like an identifier)

s1 = 'hello world'
s2 = 'hello world'
print(s1 is s2)  # False in some contexts (contains a space)
print(s1 == s2)  # True — always correct

# The golden rule:
# Use `is` only for: None, True, False, and explicit singleton checks
# Use == for everything else
x = None
if x is None:   # correct
    pass
if x == None:   # works but triggers linter warnings — avoid
    pass

Why this matters in practice: Bugs from using is instead of == are notoriously hard to find because they are environment-dependent. Code that works in development (where interning may cache the result) can silently break in production when the same strings or integers are created by different code paths.

Functions, Closures & Decorators (Q6–10)

Q6. How does Python's LEGB scope rule work, and how do closures capture variables?

What the interviewer is testing: Precise understanding of name resolution and the closure capture mechanism — especially the late-binding trap.

LEGB stands for: Local → Enclosing → Global → Built-in. When Python resolves a name, it searches each scope in this order and stops at the first match.

x = 'global'

def outer():
    x = 'enclosing'

    def inner():
        print(x)  # found in 'enclosing' scope, not global

    inner()  # prints 'enclosing'

outer()

Closures and late binding: A closure captures variable references (cell objects), not the values at the time of capture. This is Python's equivalent of JavaScript's closure-in-a-loop bug.

# The late-binding trap
functions = []
for i in range(3):
    functions.append(lambda: i)  # captures the variable i, not its current value

print([f() for f in functions])  # [2, 2, 2] — all see the final value of i

# Fix 1: default argument (evaluated at function creation time, not call time)
functions = []
for i in range(3):
    functions.append(lambda i=i: i)  # i is now a local default

print([f() for f in functions])  # [0, 1, 2]

# Fix 2: factory function (creates a new scope per iteration)
def make_fn(i):
    return lambda: i

functions = [make_fn(i) for i in range(3)]
print([f() for f in functions])  # [0, 1, 2]

Rebinding vs mutating — the nonlocal rule: nonlocal is needed to rebind (reassign) a variable in the enclosing scope. It is not needed to mutate (modify in-place) a mutable object. This distinction trips up a lot of candidates:

def make_counter():
    count = 0

    def increment():
        nonlocal count  # required: count += 1 is a rebinding (count = count + 1)
        count += 1
        return count

    return increment

counter = make_counter()
print(counter())  # 1
print(counter())  # 2

# No nonlocal needed for mutation — the list object is not rebound
def make_appender():
    items = []

    def append(x):
        items.append(x)  # mutating items in-place — no nonlocal needed
        return items

    return append

appender = make_appender()
print(appender(1))  # [1]
print(appender(2))  # [1, 2]

Q7. What is the mutable default argument trap? Why does it happen and how do you fix it?

What the interviewer is testing: Whether you understand when default argument expressions are evaluated — one of the most common Python footguns in production code.

Default argument values are evaluated once when the def statement executes, not each time the function is called. If the default is a mutable object (list, dict, set), that object is shared across all calls that use the default.

# Bug: the list is created once and shared across all calls
def append_to(item, lst=[]):
    lst.append(item)
    return lst

print(append_to(1))  # [1]
print(append_to(2))  # [1, 2] — not [2]!
print(append_to(3))  # [1, 2, 3] — the same list accumulates

# Fix: use None as sentinel, create a fresh object inside the function
def append_to(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

print(append_to(1))  # [1]
print(append_to(2))  # [2]

When the behavior is actually useful: The mutable default can be intentional as a primitive per-function cache:

def fibonacci(n, _cache={0: 0, 1: 1}):
    if n not in _cache:
        _cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
    return _cache[n]

print(fibonacci(100))  # 354224848179261915075 — fast because cache persists

This pattern is mostly a curiosity. Prefer @functools.lru_cache for production memoization (covered in Q19).

The general rule: Never use mutable objects ([], {}, set(), custom class instances) as default argument values. Always use None as a sentinel.

Q8. Explain *args, **kwargs, positional-only, and keyword-only parameters. What is the correct ordering?

What the interviewer is testing: Whether you can design function signatures that are clean, backward-compatible, and explicit.

def example(
    pos_only,          # positional-only (before /)
    /,                 # / marker: everything before is positional-only
    normal,            # can be positional or keyword
    *args,             # variadic positional — remaining positional args
    kw_only,           # keyword-only (after *args or bare *)
    **kwargs           # variadic keyword — remaining keyword args
):
    pass

Positional-only parameters (Python 3.8+, / marker): Cannot be called by keyword. Used in built-ins like len(obj, /). Useful when parameter names are implementation details you do not want as part of the public API.

def greet(name, /, greeting='Hello'):
    return f'{greeting}, {name}!'

greet('Alice')                 # OK
greet('Alice', 'Hi')           # OK
greet(name='Alice')            # TypeError — name is positional-only
greet('Alice', greeting='Hi')  # OK — greeting is not positional-only

Keyword-only parameters (after *args or a bare *): Must be called by name. Useful for flags and options that should never be passed positionally.

def process(data, *, validate=True, encoding='utf-8'):
    # validate and encoding must always be keyword arguments
    pass

process([1, 2, 3])                   # OK — defaults used
process([1, 2, 3], validate=False)   # OK
process([1, 2, 3], False)            # TypeError — cannot pass keyword-only positionally

A practical API design pattern: Marking the second-and-later parameters as keyword-only prevents callers from relying on position, giving you freedom to add or reorder parameters without breaking call sites.

Q9. How do Python decorators work? Implement a decorator with and without arguments.

What the interviewer is testing: Whether you understand that a decorator is just a callable that returns a callable, and whether you know about functools.wraps.

The mechanics: @decorator is syntactic sugar for func = decorator(func). The decorator is called once at function definition time; the wrapper it returns is what executes when you call the function.

import functools
import time

# Decorator without arguments
def timer(func):
    @functools.wraps(func)  # preserves __name__, __doc__, __annotations__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f'{func.__name__} took {elapsed:.4f}s')
        return result
    return wrapper

@timer
def slow_function(n):
    """Computes something slowly."""
    return sum(range(n))

slow_function(10_000_000)
# slow_function took 0.3271s

print(slow_function.__name__)  # 'slow_function' — preserved by @wraps
print(slow_function.__doc__)   # 'Computes something slowly.' — preserved

Decorator with arguments — requires an extra wrapping layer:

def retry(max_attempts=3, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts:
                        raise
                    print(f'Attempt {attempt} failed: {e}. Retrying...')
        return wrapper
    return decorator

@retry(max_attempts=3, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
    import requests
    return requests.get(url, timeout=5).json()

Why @functools.wraps matters: Without it, the decorated function loses its identity. Its __name__ becomes 'wrapper', its docstring is replaced, and tools like Sphinx, pytest, and stack traces show the wrong name. Always use it when writing decorators for production code.

Class-based decorators — useful when you need to maintain state between calls:

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print('Hello')

say_hello()
say_hello()
print(say_hello.call_count)  # 2

Q10. What is the difference between a generator function, an iterator, and an iterable?

What the interviewer is testing: Precision about the iteration protocol, and practical understanding of when generators save memory.

The iteration protocol has two pieces:

  • An iterable is any object with an __iter__ method that returns an iterator. Lists, tuples, strings, dicts, and generators are all iterables.
  • An iterator has both __iter__ (returning itself) and __next__ (returning the next value or raising StopIteration). Iterators are stateful and can only be consumed once.
class CountUp:
    """Manual iterator — counts from start to end."""
    def __init__(self, start, end):
        self.current = start
        self.end = end

    def __iter__(self):
        return self  # the iterator is also an iterable

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration
        value = self.current
        self.current += 1
        return value

for n in CountUp(0, 3):
    print(n)  # 0, 1, 2

A generator function uses yield to produce values lazily. Python automatically creates the iterator protocol for you. The function body does not execute when called — it only executes on each next() call, pausing at each yield.

def count_up(start, end):
    """Generator version of CountUp — far simpler."""
    for i in range(start, end):
        yield i

gen = count_up(0, 3)
print(next(gen))  # 0
print(next(gen))  # 1
print(next(gen))  # 2
# next(gen) → StopIteration

# Generators are memory-efficient for large sequences
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

from itertools import islice
print(list(islice(fibonacci(), 10)))
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
# The infinite sequence never fully materializes in memory

Generator expressions — lazy list comprehensions:

numbers = range(1_000_000)

# List comprehension: creates 1M items in memory immediately
squares_list = [x * x for x in numbers]

# Generator expression: creates one item at a time — memory stays O(1)
squares_gen = (x * x for x in numbers)
total = sum(squares_gen)  # only one item exists at a time during iteration

When to use generators over lists: When the sequence is large or infinite, when you only need to iterate once, or when each item is expensive to compute and you want to process results as they arrive. Use a list when you need random access, multiple iterations, or the len() function.

yield from — generator delegation: yield from iterable delegates to a sub-generator, forwarding values, exceptions, and return values bidirectionally. It is the correct way to compose generators and is frequently tested at the middle-developer level.

def chain(*iterables):
    """Simplified implementation of itertools.chain using yield from."""
    for it in iterables:
        yield from it  # equivalent to: for item in it: yield item
                       # but also passes send() and throw() through correctly

list(chain([1, 2], [3, 4], [5]))  # [1, 2, 3, 4, 5]

# yield from with a return value — captures the sub-generator's return
def accumulate(values):
    total = 0
    for v in values:
        total += v
        yield total
    return total  # only accessible via StopIteration.value or yield from

def pipeline(data):
    final_total = yield from accumulate(data)  # captures the return value
    print(f'Final total was: {final_total}')

Why yield from matters beyond readability: It correctly handles the send() and throw() protocol — values sent to the outer generator are forwarded to the inner one, and exceptions thrown from the outer are raised at the yield point inside the inner generator. A simple for item in sub: yield item loop does not do this. asyncio was originally built on top of this coroutine delegation mechanism before async/await syntax was introduced.

Object-Oriented Python in Depth (Q11–15)

Q11. How does Python's Method Resolution Order (MRO) work with multiple inheritance?

What the interviewer is testing: Whether you understand how Python resolves method names in complex inheritance hierarchies and why the diamond problem does not cause ambiguity in Python.

Python uses the C3 linearization algorithm to compute a deterministic method resolution order. The MRO is a list of classes in the order Python will search them for a method. You can inspect it with ClassName.__mro__.

class A:
    def method(self): return 'A'

class B(A):
    def method(self): return 'B'

class C(A):
    def method(self): return 'C'

class D(B, C):
    pass  # diamond: D → B → C → A

print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

print(D().method())  # 'B' — B comes before C in the MRO

The C3 rule in plain English: A class always comes before its parents in the MRO, and if a class appears at multiple positions, the first declaration order (left to right in the class definition) is honored. Python raises a TypeError if it cannot construct a consistent order.

super() and cooperative multiple inheritance: super() does not call the parent class — it calls the next class in the MRO. This is critical for correct cooperative behavior.

class A:
    def __init__(self):
        print('A.__init__')
        super().__init__()

class B(A):
    def __init__(self):
        print('B.__init__')
        super().__init__()  # calls C.__init__, not A — follows MRO

class C(A):
    def __init__(self):
        print('C.__init__')
        super().__init__()

class D(B, C):
    def __init__(self):
        print('D.__init__')
        super().__init__()

D()
# D.__init__
# B.__init__
# C.__init__
# A.__init__
# Each __init__ called exactly once — cooperative multiple inheritance

Practical advice: Multiple inheritance beyond simple mixin patterns is a design smell in most application code. Use it for mixins — small, focused classes that add a single capability — and understand the MRO so you can debug it when needed.

Q12. What is the difference between __new__ and __init__ in Python? When would you override __new__?

What the interviewer is testing: Whether you know when __init__ is not enough — specifically for immutable type subclassing and singleton patterns. If your answer starts with "well, both are used for initialization," the interviewer already knows you've memorized rather than understood.

__new__ is the static method that creates and returns a new instance of the class. It receives the class as its first argument and must return an instance. __init__ is the initializer — it receives the already-created instance and sets it up. __new__ runs first; __init__ is only called if __new__ returns an instance of the class (or a subclass).

# The call sequence for MyClass(args):
# 1. instance = MyClass.__new__(MyClass, args)
# 2. if isinstance(instance, MyClass): MyClass.__init__(instance, args)

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

a = Singleton()
b = Singleton()
print(a is b)  # True

Another use case — immutable type subclasses: int, str, tuple, and frozenset are immutable. By the time __init__ runs, the value is already set. To customize their initialization, you must override __new__.

class PositiveInt(int):
    def __new__(cls, value):
        if value <= 0:
            raise ValueError(f'PositiveInt requires a positive value, got {value}')
        return super().__new__(cls, value)

x = PositiveInt(5)   # 5
y = PositiveInt(-1)  # ValueError

In practice: Override __new__ for: singletons, immutable type subclasses, and metaclasses (though prefer dataclasses or attrs for most initialization logic, and __init_subclass__ over metaclasses for plugin registration patterns).

Q13. What are __slots__ and when should you use them?

What the interviewer is testing: Memory optimization awareness and knowledge of Python's object internals.

By default, every Python instance has a __dict__ — a dictionary that stores its attributes. This is flexible but expensive: a dictionary for every instance consumes significant memory, even for simple objects with two or three attributes.

__slots__ replaces the per-instance __dict__ with a fixed set of slot descriptors defined at the class level. This saves memory and slightly speeds up attribute access.

import sys

class PointDict:  # default — has __dict__
    def __init__(self, x, y):
        self.x = x
        self.y = y

class PointSlots:  # uses __slots__
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

p1 = PointDict(1, 2)
p2 = PointSlots(1, 2)

print(sys.getsizeof(p1))           # ~48 bytes (object header)
print(sys.getsizeof(p1.__dict__))  # ~232 bytes (dict overhead)
print(sys.getsizeof(p2))           # ~56 bytes — no dict, just slots

At scale — a million coordinate objects in a geospatial application — __slots__ can reduce memory usage by 50–70%.

Trade-offs of __slots__:

  • Cannot add arbitrary attributes to instances — only attributes named in __slots__ are allowed
  • No __dict__ means reduced compatibility with some libraries that expect it (e.g., some serializers, pickle by default)
  • Subclasses that do not define their own __slots__ get a __dict__ again, negating the savings
  • Weak references require adding '__weakref__' to __slots__ explicitly

When to use: Data-heavy applications where you create large numbers of simple objects. Sensor data records, game entities, coordinate objects, event log entries. Not worth it for most business logic classes.

Q14. What is the difference between a classmethod, staticmethod, and an instance method?

What the interviewer is testing: Clean API design. Choosing the wrong method type makes an API harder to use and extend.

  • Instance method: First argument is self (the instance). Has access to instance state and class state. The default kind of method.
  • Class method (@classmethod): First argument is cls (the class, not an instance). Has access to class state but not instance state. Commonly used for alternative constructors.
  • Static method (@staticmethod): No automatic first argument. Just a regular function namespaced inside the class. Use when the logic is related to the class but needs neither instance nor class state.
from datetime import date

class Person:
    species = 'Homo sapiens'

    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

    def age(self):
        """Instance method — needs self."""
        return date.today().year - self.birth_year

    @classmethod
    def from_birth_date(cls, name, birth_date: date):
        """Alternative constructor — the classmethod pattern."""
        return cls(name, birth_date.year)

    @classmethod
    def get_species(cls):
        return cls.species

    @staticmethod
    def is_valid_name(name: str) -> bool:
        """Pure utility — no self or cls needed."""
        return isinstance(name, str) and len(name.strip()) > 0

alice = Person.from_birth_date('Alice', date(1990, 5, 15))
print(alice.age())                  # current year minus 1990
print(Person.get_species())         # 'Homo sapiens'
print(Person.is_valid_name('Bob'))  # True

Why classmethods for alternative constructors? Because they receive cls, they work correctly in subclasses. If Employee(Person) calls Employee.from_birth_date(...), it returns an Employee instance, not a Person. A staticmethod would require hardcoding the class name.

Q15. How do dataclasses work and when do you prefer them over namedtuples or regular classes?

What the interviewer is testing: Practical API design and knowing when to use the right tool for structured data.

@dataclass (Python 3.7+) auto-generates __init__, __repr__, __eq__, and optionally __hash__, __lt__ and others, based on class-level annotated fields.

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass(order=True, frozen=False)
class Product:
    name: str
    price: float
    tags: list[str] = field(default_factory=list)  # mutable default — must use field()
    _tax_rate: ClassVar[float] = 0.2  # class variable, not a dataclass field

    def total_price(self) -> float:
        return self.price * (1 + self._tax_rate)

    def __post_init__(self):
        if self.price < 0:
            raise ValueError('Price cannot be negative')

p1 = Product('Widget', 9.99)
p2 = Product('Widget', 9.99)
print(p1 == p2)   # True — __eq__ generated
print(repr(p1))   # Product(name='Widget', price=9.99, tags=[])
p1.tags.append('sale')
print(p1)         # Product(name='Widget', price=9.99, tags=['sale'])

Key options:

  • frozen=True: Makes instances immutable (raises FrozenInstanceError on assignment) and makes them hashable. Similar to namedtuples but with richer features.
  • order=True: Generates __lt__, __le__, __gt__, __ge__ based on field order — enables sorting.
  • field(default_factory=...): Required for mutable defaults — the factory is called per instance, avoiding the mutable default trap from Q7.
  • __post_init__: Called after the generated __init__. Use for validation and derived field computation.

When to choose what:

  • Regular class: Complex behavior, many methods, not primarily data storage
  • Dataclass: Structured data that needs methods, validation, or both fields and behavior
  • NamedTuple: Immutable, lightweight, needs tuple behavior (unpacking, indexing), minimal methods
  • Pydantic: When you need runtime validation, serialization, or API model parsing — the standard for FastAPI request/response models and config parsing

Concurrency, Async & Performance (Q16–20)

Q16. What is the difference between threading, multiprocessing, and asyncio? When do you use each?

What the interviewer is testing: Whether you can reason about concurrency vs parallelism and choose the right tool for the problem.

The question always starts with: is my bottleneck I/O-bound or CPU-bound?

  • asyncio (cooperative multitasking): Single thread, single process. Coroutines yield control voluntarily when waiting. Near-zero context-switch overhead. Perfect for I/O-bound work at high concurrency — HTTP servers, database clients, WebSockets. FastAPI and aiohttp are built on this model.
  • threading (preemptive multitasking): Multiple threads in one process, one shared GIL. True concurrency for I/O-bound work (GIL is released during blocking calls). Simpler mental model for legacy code or when you need to call blocking libraries that are not async-aware.
  • multiprocessing (true parallelism): Multiple processes, separate memory spaces, separate GILs. True CPU parallelism. Use for CPU-bound work: data processing, image manipulation, ML inference, encryption. IPC overhead is the cost.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests

# I/O bound — threading or asyncio
def fetch_url(url):
    return requests.get(url).status_code

urls = ['https://httpbin.org/delay/1'] * 10
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(fetch_url, urls))
# Wall time ~1s despite 10 requests — they wait concurrently

# CPU bound — multiprocessing
def heavy_compute(n):
    return sum(i ** 2 for i in range(n))

with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(heavy_compute, [5_000_000] * 8))
# 8 tasks across 4 cores in parallel

Decision rule: CPU-bound? → multiprocessing. I/O-bound, high concurrency, new code? → asyncio. I/O-bound, existing blocking codebase? → threading.

concurrent.futures — the unified high-level API: Both ThreadPoolExecutor and ProcessPoolExecutor share the same interface. You can swap one for the other with a single character change, which is useful when benchmarking whether your bottleneck is I/O or CPU. Use executor.submit() for individual tasks with Future objects, or executor.map() for batch processing with automatic result ordering. These are the correct tools for 90% of concurrency needs — reach for raw threading.Thread or multiprocessing.Process only when you need fine-grained control over thread lifecycle, shared memory, or process group management.

Q17. How does Python's asyncio event loop work? What does await actually do?

What the interviewer is testing: Whether you understand how async Python actually executes — not just the syntax.

The core model: asyncio's event loop is a scheduler that runs coroutines cooperatively. A coroutine is a function defined with async def. Calling a coroutine returns a coroutine object — it does not execute it. Execution happens when the coroutine is scheduled via asyncio.create_task() or await.

What await does: It suspends the current coroutine and yields control back to the event loop. The event loop then runs other ready tasks. When the awaited operation completes, the event loop resumes the suspended coroutine at the point after await.

import asyncio

async def fetch_data(name, delay):
    print(f'{name}: starting')
    await asyncio.sleep(delay)  # yields control — simulates network I/O
    print(f'{name}: done after {delay}s')
    return f'{name}_result'

async def main():
    # Sequential: total time = 2 + 1 = 3 seconds
    # result1 = await fetch_data('A', 2)
    # result2 = await fetch_data('B', 1)

    # Concurrent: total time = max(2, 1) = 2 seconds
    task1 = asyncio.create_task(fetch_data('A', 2))
    task2 = asyncio.create_task(fetch_data('B', 1))
    result1 = await task1
    result2 = await task2
    print(result1, result2)

asyncio.run(main())
# B: starting
# A: starting
# B: done after 1s
# A: done after 2s

The critical point about sequential awaits: Two consecutive await calls run sequentially, not concurrently. If you await fetch('A') then await fetch('B'), B does not start until A finishes. Use create_task() or gather() to start coroutines concurrently.

What cannot be awaited — the blocking code trap: If you call a blocking synchronous function inside an async function — a synchronous DB query, a CPU-heavy calculation, time.sleep() — it blocks the entire event loop. Nothing else runs. Use asyncio.to_thread() (Python 3.9+) to run blocking code in a thread pool without blocking the loop.

async def main():
    # WRONG: blocks the event loop for 1 second
    import time
    time.sleep(1)

    # CORRECT: runs in a thread, yields control while waiting
    await asyncio.to_thread(time.sleep, 1)

Q18. What is the difference between asyncio.gather, asyncio.wait, and asyncio.TaskGroup?

What the interviewer is testing: Practical async Python and awareness of modern APIs introduced in Python 3.11.

  • asyncio.gather(*coros): Schedules all coroutines/tasks concurrently and returns a list of results in the same order as input. By default, if any coroutine raises, the exception propagates immediately and remaining tasks are cancelled. Use return_exceptions=True to collect exceptions as results instead. The most common choice for "run N things in parallel and get all results."
  • asyncio.wait(tasks, return_when=...): More granular control. Returns two sets: done and pending. Use return_when=asyncio.FIRST_COMPLETED to react as soon as any task finishes, or FIRST_EXCEPTION to stop on first failure. Better when you need to process results as they arrive.
  • asyncio.TaskGroup (Python 3.11+): The modern idiomatic choice. Tasks created inside the context manager run concurrently; the block exits when all finish. If any task raises, remaining tasks are cancelled and all exceptions are surfaced as an ExceptionGroup. Structured concurrency — tasks cannot be lost to garbage collection.
import asyncio

# gather — simple parallel execution with optional error collection
async def use_gather():
    results = await asyncio.gather(
        fetch('/api/users'),
        fetch('/api/orders'),
        return_exceptions=True,  # don't fail-fast; collect errors as values
    )
    for r in results:
        if isinstance(r, Exception):
            print(f'Error: {r}')

# TaskGroup — Python 3.11+, structured concurrency
async def use_task_group():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(fetch('/api/users'))
        task2 = tg.create_task(fetch('/api/orders'))
    # Both tasks done here. ExceptionGroup raised if any failed.
    print(task1.result(), task2.result())

Recommendation: Use TaskGroup when targeting Python 3.11+. Use gather for broader compatibility. Use wait when you genuinely need partial-completion behavior (e.g., process each result as it arrives and cancel the rest after the first success).

Q19. How does functools.lru_cache work, and what are its pitfalls?

What the interviewer is testing: Practical caching knowledge and awareness of when caches silently misbehave.

@functools.lru_cache(maxsize=128) memoizes function results in a fixed-size LRU cache keyed by the arguments. Repeated calls with the same arguments return the cached result instantly.

from functools import lru_cache

@lru_cache(maxsize=None)  # None = unbounded cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(100)  # O(n) — each subproblem computed once
print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
fibonacci.cache_clear()  # clear cache when inputs change or for testing

Pitfall 1 — arguments must be hashable: lru_cache uses arguments as dict keys. Lists, dicts, and sets are not hashable and will raise a TypeError.

@lru_cache
def process(data):
    return sum(data)

process([1, 2, 3])           # TypeError: unhashable type: 'list'
process(tuple([1, 2, 3]))    # OK

Pitfall 2 — no TTL (time-to-live): lru_cache never expires automatically. If the underlying data changes, the cache returns stale results until explicitly cleared. Only use it for pure functions — same inputs always produce the same output.

Pitfall 3 — memory growth with unbounded cache: maxsize=None stores every unique call ever made. For functions called with many distinct arguments, this is a memory leak. Set a reasonable maxsize in production.

Pitfall 4 — method caching leaks every instance: lru_cache on instance methods is a memory leak. The cache lives on the function object (shared across all instances). Because self is part of the cache key, every instance that calls the method is held in the cache indefinitely — the instance's reference count never reaches zero, so it is never garbage collected.

import functools

class DataProcessor:
    def __init__(self, data):
        self.data = data

    @functools.lru_cache(maxsize=128)  # BUG: cache lives on DataProcessor.process
    def process(self, multiplier):     # every `self` that ever calls this is retained
        return [x * multiplier for x in self.data]

# Each instance is leaked:
for _ in range(1000):
    dp = DataProcessor(list(range(100)))
    dp.process(2)
# All 1000 DataProcessor instances are still alive in the lru_cache

# Fix 1: functools.cached_property for no-argument computed properties
class DataProcessor:
    def __init__(self, data):
        self.data = data

    @functools.cached_property          # stored in instance __dict__, garbage collected with instance
    def processed(self):
        return [x * 2 for x in self.data]

# Fix 2: cache on the instance itself (explicit, safe)
class DataProcessor:
    def __init__(self, data):
        self.data = data
        self._cache = {}

    def process(self, multiplier):
        if multiplier not in self._cache:
            self._cache[multiplier] = [x * multiplier for x in self.data]
        return self._cache[multiplier]

Q20. How do you profile a Python application and identify performance bottlenecks?

What the interviewer is testing: Production experience. Can you diagnose a slow Python program, not just guess about it?

The profiling hierarchy — start coarse, then go fine-grained:

1. cProfile — deterministic profiler, good for identifying the slow function:

python -m cProfile -s cumulative my_script.py | head -20

# Or programmatically:
import cProfile
import pstats

with cProfile.Profile() as pr:
    your_expensive_function()

stats = pstats.Stats(pr)
stats.sort_stats('cumulative')
stats.print_stats(20)  # top 20 by cumulative time

2. line_profiler — per-line timing once you know which function is slow: Add @profile to the function, then run with kernprof -l -v script.py. Shows time per line — invaluable for pinpointing the exact hotspot.

3. tracemalloc — memory profiling:

import tracemalloc

tracemalloc.start()

# ... code to profile ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:5]:
    print(stat)
# prints file:line — size — count for the top memory allocators

4. py-spy — production-safe sampling profiler: Attaches to a running Python process without modifying code or adding significant overhead. Generates flame graphs. The standard tool for profiling live production services without restarting them.

py-spy record -o profile.svg --pid 12345 --duration 30

Common bottlenecks at the middle-developer level:

  • N+1 queries — calling the database inside a loop instead of with a single batched query
  • String concatenation in loops — result += chunk is O(n²); use ''.join(chunks) instead
  • Loading large files entirely into memory instead of streaming with generators
  • Using lists where sets or dicts would give O(1) membership testing
  • Redundant computation in hot paths — cache or precompute invariants outside the loop

Data Structures & the Type System (Q21–25)

Q21. How does Python's dict work under the hood?

What the interviewer is testing: Understanding of why dicts are fast, what makes them fail silently, and when to reach for alternatives.

Python's dict is a hash table. A key is hashed with hash(key); the hash determines a slot index in the underlying array. If two keys hash to the same slot (collision), Python uses open addressing with pseudorandom probing to find the next available slot.

Compact dict implementation (CPython 3.6+): CPython splits the dict into two arrays: a sparse index array (for O(1) hash lookup) and a compact entries array (for ordered iteration). This reduced memory usage by ~30% compared to the original design and, as a side effect, made insertion order preservation practically free — which became an official language guarantee in Python 3.7. This is why iterating a dict in insertion order is fast: it walks a dense, cache-friendly array, not a sparse hash table.

Insertion order guarantee (Python 3.7+): dict is both a hash map and an ordered structure.

Performance characteristics:

  • Average O(1) lookup, insertion, deletion
  • Worst case O(n) with adversarial hash collisions — hash randomization (on by default since Python 3.3) makes this practically irrelevant for untrusted inputs
  • Roughly 3–5× more memory-intensive than a list of the same number of items (hash table overhead)
# Dict as a fast lookup table (O(1) vs list O(n))
large_list = list(range(1_000_000))
large_dict = {i: True for i in range(1_000_000)}

999_999 in large_list  # O(n) — iterates up to 1M items
999_999 in large_dict  # O(1) — hash lookup

# Word frequency counter
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the"
counts = Counter(text.split())
print(counts.most_common(3))  # [('the', 3), ('quick', 1), ('brown', 1)]

Hashability requirement: Dict keys must be hashable. Hashable built-ins: int, float, str, tuple (if all elements are hashable), frozenset. Not hashable: list, dict, set. Custom classes are hashable by default (using id()-based hash) unless you define __eq__ without __hash__.

collections.defaultdict and Counter: Both are dict subclasses that keep all O(1) performance guarantees while adding convenience. defaultdict(list) returns an empty list for missing keys, eliminating the if key not in d: d[key] = [] pattern. Counter is a frequency map with arithmetic operators and most_common(n). Both are the correct tool when you catch yourself initializing dict values defensively:

from collections import defaultdict, Counter

# defaultdict — no KeyError, no setdefault boilerplate
word_positions = defaultdict(list)
for i, word in enumerate("the cat sat on the mat".split()):
    word_positions[word].append(i)
# word_positions['the'] → [0, 4], word_positions['xyz'] → [] (not KeyError)

# Counter — frequency map with extras
letters = Counter("mississippi")
print(letters.most_common(3))  # [('s', 4), ('i', 4), ('p', 2)]
print(letters['z'])            # 0 — not KeyError

# Counter arithmetic
a = Counter("aab")
b = Counter("bbc")
print(a + b)  # Counter({'b': 3, 'a': 2, 'c': 1})
print(a - b)  # Counter({'a': 2}) — only positive counts kept

Q22. What is the difference between list, collections.deque, and array.array? When do you choose each?

What the interviewer is testing: Whether you choose data structures based on access patterns, not habit.

  • list: Dynamic array. O(1) amortized append to the right and random access by index. O(n) insert or delete at an arbitrary position (shifting elements). O(n) insert or pop from the left. The default choice for sequences.
  • collections.deque: Double-ended queue. O(1) append and pop from both ends. O(n) random access by index. Use when you need a FIFO queue, a sliding window, or BFS traversal.
  • array.array: Stores homogeneous primitive values (all ints, all floats) in a compact C array. 2–4× more memory-efficient than a list for numeric data because it stores raw C types rather than Python objects. Does not support mixed types. Use for large arrays of numbers when you do not need NumPy.
from collections import deque
import array, sys

# deque for queue — O(1) both ends
queue = deque()
queue.append('first')
queue.append('second')
queue.appendleft('zeroth')   # O(1) — unlike list.insert(0, ...)
print(queue.popleft())       # 'zeroth' — O(1) — unlike list.pop(0)

# Sliding window with maxlen
recent = deque(maxlen=3)  # automatically drops oldest when full
for item in range(6):
    recent.append(item)
print(list(recent))  # [3, 4, 5]

# array for memory-efficient numeric storage
numbers_list  = list(range(100_000))
numbers_array = array.array('i', range(100_000))  # 'i' = signed int
print(sys.getsizeof(numbers_list))   # ~800,056 bytes
print(sys.getsizeof(numbers_array))  # ~400,064 bytes — roughly 2x smaller

Q23. How does Python's copy module work? Explain shallow and deep copy with a concrete example.

What the interviewer is testing: Awareness of object aliasing bugs — a common source of mysterious state mutations.

Assignment is aliasing, not copying: b = a makes b point to the same object as a. Mutating through b is visible through a.

a = [1, [2, 3], 4]
b = a           # alias — same object
b.append(5)
print(a)        # [1, [2, 3], 4, 5] — a is affected!

import copy

# Shallow copy: new outer container, but nested objects are still shared
c = copy.copy(a)   # or: a[:] or list(a)
c.append(99)       # c gets 99; a is unchanged for top-level items
c[1].append(99)    # c[1] and a[1] are the SAME nested list — both affected!
print(a[1])        # [2, 3, 99] — shallow copy does not protect nested objects

# Deep copy: recursively copies everything — fully independent
a = [1, [2, 3], 4]
d = copy.deepcopy(a)
d[1].append(99)
print(a[1])        # [2, 3] — unaffected
print(d[1])        # [2, 3, 99]

When to use which:

  • Alias (=): Intentional shared state — you want both names to see the same mutations
  • Shallow copy: When the structure is flat (no nested mutables), or sharing nested objects is intentional
  • Deep copy: When you need a fully independent clone. Common for: undo/redo history, test fixture isolation, config objects that should not cross-contaminate

Custom deep copy: Implement __deepcopy__(self, memo) to control how your class is deep-copied — useful for objects containing non-serializable resources (file handles, sockets, locks) that should not be duplicated.

Q24. How do Python type hints work? What is the difference between Protocol and ABC?

What the interviewer is testing: Modern Python API design and understanding of structural vs nominal typing.

Type hints (PEP 484) are annotations that document expected types. They are not enforced at runtime by default — they are for static analysis tools (mypy, pyright) and IDEs. Modern Python (3.10+) syntax is clean and does not require most imports from the typing module.

def process(
    items: list[int],           # Python 3.9+ — no need for List from typing
    label: str | None = None,   # Python 3.10+ — union with | operator
    multiplier: float = 1.0,
) -> dict[str, int]:
    return {str(i * multiplier): i for i in items}

Protocol (structural subtyping / duck typing): Defines a set of methods a type must have, checked statically without requiring explicit inheritance. A class "implements" a Protocol simply by having the required methods.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...
    def resize(self, factor: float) -> None: ...

class Circle:
    def draw(self) -> None: print('Drawing circle')
    def resize(self, factor: float) -> None: self.radius *= factor

class Square:
    def draw(self) -> None: print('Drawing square')
    def resize(self, factor: float) -> None: self.side *= factor

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())  # works — satisfies Drawable structurally
render(Square())  # works — no inheritance required

print(isinstance(Circle(), Drawable))  # True with @runtime_checkable

Abstract Base Class (ABC) — nominal subtyping: Requires explicit inheritance. Provides @abstractmethod enforcement at instantiation time — raises TypeError if a subclass does not implement all required methods.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    @abstractmethod
    def perimeter(self) -> float: ...

class Triangle(Shape):
    def __init__(self, base, height, sides):
        self.base, self.height, self.sides = base, height, sides

    def area(self) -> float:
        return 0.5 * self.base * self.height

    def perimeter(self) -> float:
        return sum(self.sides)

# Shape()  → TypeError: Can't instantiate abstract class Shape

When to use Protocol vs ABC: Use Protocol when you want to accept any object that "looks like" the right shape — classic duck typing with static analysis support. This avoids imposing inheritance requirements on callers. Use ABC when you are building a framework with explicit extension points and want runtime enforcement that subclasses implement all required methods.

Q25. What are metaclasses in Python and what problems do they solve?

What the interviewer is testing: Whether you understand that Python's class system is itself built with Python — classes are objects, and the machinery that creates them is accessible and customizable. Interviewers rarely expect metaclasses in production; they ask this to see if you have read below the surface of the language.

In Python, everything is an object — including classes. A metaclass is the class of a class. The default metaclass is type. Just as int is the class of integers, type is the class of classes. You can customize class creation by defining a metaclass that overrides __new__, __init__, or __prepare__.

print(type(int))   # <class 'type'>
print(type(list))  # <class 'type'>

class Foo: pass
print(type(Foo))   # <class 'type'> — Foo's metaclass is type

A real use case — auto-registering subclasses:

class PluginMeta(type):
    registry = {}

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if bases:  # skip the base Plugin class itself
            PluginMeta.registry[name] = cls
        return cls

class Plugin(metaclass=PluginMeta):
    pass

class AudioPlugin(Plugin): pass
class VideoPlugin(Plugin): pass

print(PluginMeta.registry)
# {'AudioPlugin': <class 'AudioPlugin'>, 'VideoPlugin': <class 'VideoPlugin'>}
# Subclasses automatically register on class definition — no manual call needed

Should you use metaclasses? Rarely in application code. Metaclasses are for framework authors — Django's ORM, SQLAlchemy's declarative base, and Python's own ABCMeta all use them. For application code, __init_subclass__ usually accomplishes the same goal with far less complexity.

# Simpler alternative to metaclass: __init_subclass__ (Python 3.6+)
class Plugin:
    _registry = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Plugin._registry[cls.__name__] = cls

class AudioPlugin(Plugin): pass
class VideoPlugin(Plugin): pass

print(Plugin._registry)
# {'AudioPlugin': <class 'AudioPlugin'>, 'VideoPlugin': <class 'VideoPlugin'>}

Tricky Behavior & Modern Python (Q26–30)

The five questions in this section come up in interviews precisely because they expose gaps that syntax familiarity hides. A developer can write Python for three years and never deliberately inspect f.__closure__ or write a context manager without the with open() idiom. These questions test whether you have explored the edges of the language, not just the comfortable middle.

Q26. What are Python cell objects, and how does Python implement closures internally?

What the interviewer is testing: Deep language internals. This separates developers who understand Python's execution model from those who just use it.

When a nested function references a variable from an enclosing scope, Python cannot store it in either function's local variable array — the inner function might outlive the outer function's stack frame. Instead, Python uses a cell object: a small wrapper that both the outer and inner functions reference. The variable's value lives in the cell, and both access it via the cell.

def outer():
    x = 10

    def inner():
        return x  # x is a free variable — stored in a cell

    return inner

f = outer()
print(f())  # 10 — outer's stack frame is gone, but x lives in the cell

# Inspect closure cells:
print(f.__closure__)                     # (<cell at 0x...>,)
print(f.__closure__[0].cell_contents)    # 10

# What 'nonlocal' does:
def counter():
    count = 0

    def increment():
        nonlocal count  # tells Python: count is in a cell, don't make a new local
        count += 1
        return count

    return increment

Why this matters: Understanding cell objects explains why the late-binding trap from Q6 exists (all closures in a loop share the same cell for the loop variable), and why nonlocal is needed for rebinding (without it, the assignment creates a new local variable, shadowing the cell).

Q27. How do context managers work? Implement one using __enter__/__exit__ and contextlib.contextmanager.

What the interviewer is testing: Whether you understand the resource management protocol and can implement it both ways.

The with statement calls __enter__ on entry and __exit__ on exit — even if an exception occurs. __exit__ receives the exception type, value, and traceback. If it returns a truthy value, the exception is suppressed.

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self  # value bound to the `as` variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        print(f'Elapsed: {self.elapsed:.4f}s')
        return False  # don't suppress exceptions

with Timer() as t:
    total = sum(range(10_000_000))

print(f'Captured: {t.elapsed:.4f}s')  # also accessible after the block

Using contextlib.contextmanager — generator-based, often simpler:

from contextlib import contextmanager

@contextmanager
def managed_connection(host, port):
    conn = create_connection(host, port)
    try:
        yield conn  # the value bound to `as`
    except Exception:
        conn.rollback()
        raise  # re-raise — never swallow exceptions silently
    finally:
        conn.close()  # always runs, even on exception

with managed_connection('localhost', 5432) as conn:
    conn.execute('SELECT 1')
# conn.close() is guaranteed to run

Real use cases beyond file handling: Database transactions, temporary directory creation and cleanup, mocking in tests (unittest.mock.patch is a context manager), acquiring and releasing locks, and temporarily redirecting stdout.

contextlib.suppress — for selectively ignoring specific exceptions:

from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove('temp_file.txt')  # silently ignored if file does not exist

Q28. What is the difference between raise, raise e, and raise from e in Python?

What the interviewer is testing: Clean exception handling — specifically whether you preserve or obscure stack traces.

  • raise — re-raises the current exception with its original traceback intact. Use inside except blocks when you want to do a side effect (logging, cleanup) and then propagate unchanged.
  • raise e — raises the exception but resets the traceback to the current line. The original traceback is lost. Almost never what you want — it makes debugging significantly harder.
  • raise NewError(...) from e — raises a new exception while chaining the original as its __cause__. Python prints both. Use when translating exceptions across abstraction layers.
  • raise NewError(...) from None — suppresses the chained context. Use when the original is an internal implementation detail callers should not see.
def read_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f'Config not found: {path}') from e
        # Traceback shows BOTH: ConfigError AND the original FileNotFoundError

def load_user(user_id):
    try:
        return db.query(user_id)
    except Exception:
        raise  # re-raises with original traceback — correct

def parse_user_id(raw):
    try:
        return int(raw)
    except ValueError as e:
        raise e  # DON'T DO THIS — traceback now starts here, original context lost

# raise from None — suppress context intentionally
class APIClient:
    def get_user(self, user_id):
        try:
            return self._http_get(f'/users/{user_id}')
        except HTTPError as e:
            if e.status_code == 404:
                # HTTPError is an internal detail — callers should see UserNotFoundError only
                raise UserNotFoundError(user_id) from None  # __cause__ and __context__ both suppressed
            raise

Exception chaining in practice: raise X from Y is the correct pattern for library authors — translate low-level exceptions (database errors, network errors) into domain-specific exceptions (UserNotFoundError, ConnectionFailed) while preserving the underlying cause for debugging.

Q29. What is the walrus operator (:=) and when should you actually use it?

What the interviewer is testing: Awareness of modern Python features and the judgment to apply them without overuse.

The walrus operator (:=, PEP 572, Python 3.8+) is an assignment expression. It assigns a value to a variable and evaluates to that value, all in a single expression. It is called the walrus operator because := resembles a walrus.

# Without walrus — duplicate call or extra variable
line = f.readline()
while line:
    process(line)
    line = f.readline()

# With walrus — clean, no duplication
while line := f.readline():
    process(line)

# Filtering where the transformation is expensive
results = [
    processed
    for x in data
    if (processed := expensive_transform(x)) is not None
]
# Without walrus: expensive_transform would run twice — once for filter, once for value

# Capturing a regex match (common idiom)
import re
if m := re.search(r'(\d+)', text):
    print(m.group(1))  # m is in scope

When to use it: The walrus operator genuinely improves readability when it eliminates repeated computation or removes an assignment that exists only to feed a condition. Use it when the assignment is directly related to the condition. Avoid it when it makes an expression harder to parse.

Common overuse to avoid: Do not use := just to show you know it exists. Nested walrus operators in complex expressions are a maintenance problem. Treat it like any tool — use it when it makes the code clearer, not as a style statement.

Q30. What is structural pattern matching (match/case) and how does it work?

What the interviewer is testing: Knowledge of Python 3.10+ features and whether you understand how match/case differs from a simple if/elif chain.

Structural pattern matching (match/case, PEP 634, Python 3.10+) is not a switch statement. It tests an object's structure, type, and values simultaneously and binds matched parts to names.

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

def classify_point(point):
    match point:
        case Point(x=0, y=0):
            return 'origin'
        case Point(x=0, y=y):      # x is 0, bind y
            return f'on y-axis at {y}'
        case Point(x=x, y=0):      # y is 0, bind x
            return f'on x-axis at {x}'
        case Point(x=x, y=y) if x == y:  # guard clause
            return f'on diagonal at {x}'
        case Point(x=x, y=y):
            return f'general point ({x}, {y})'

print(classify_point(Point(0, 5)))  # on y-axis at 5
print(classify_point(Point(3, 3)))  # on diagonal at 3

Matching on command structures — where match/case shines:

def handle_command(command):
    match command.split():
        case ['quit']:
            return 'Quitting'
        case ['go', direction] if direction in ('north', 'south', 'east', 'west'):
            return f'Going {direction}'
        case ['get', obj, 'from', container]:
            return f'Getting {obj} from {container}'
        case ['help', *args]:    # *args captures remaining words
            return f'Help requested for: {args}'
        case _:                  # wildcard — matches anything
            return f'Unknown command: {command}'

print(handle_command('go north'))                # Going north
print(handle_command('get key from chest'))      # Getting key from chest
print(handle_command('help inventory spells'))   # Help requested for: ['inventory', 'spells']

Matching on types and mappings:

def process_event(event):
    match event:
        case {'type': 'click', 'x': x, 'y': y}:  # mapping pattern
            handle_click(x, y)
        case {'type': 'keypress', 'key': key}:
            handle_key(key)
        case int() | float():     # OR pattern — matches numeric types
            handle_number(event)
        case str() as text if len(text) > 0:
            handle_text(text)

When to use match/case vs if/elif: Use match when your code branches on the structure or type of an object — command parsing, event handling, AST processing, protocol message dispatch. It avoids repeated attribute access, binds captured values automatically, and makes complex branching logic significantly more readable. Use if/elif for simple value comparisons where the extra syntax of match adds no clarity.

How to Prepare Effectively and Perform Well Under Pressure

Technical knowledge alone does not win interviews. The developer who can clearly explain why something works the way it does, and articulate trade-offs between approaches, consistently outperforms the developer who can recite more facts.

How to structure every technical answer

For any conceptual question, use this three-part structure: mechanism → implication → example.

  • Mechanism: What actually happens at the language or runtime level — precision
  • Implication: What breaks or gets subtle if you misunderstand it — judgment
  • Example: One concrete code snippet or real production experience — authority

"The GIL prevents threads from running Python bytecode simultaneously because CPython's reference counting is not thread-safe. The implication is that threading gives no CPU parallelism — but does give concurrency for I/O because the GIL is released during blocking calls. In practice, I use ThreadPoolExecutor for HTTP request batches and multiprocessing.Pool for CPU-heavy data processing." That is a complete, senior-quality answer in four sentences.

What to do when you do not know

Say so directly, then reason out loud. "I haven't used asyncio.TaskGroup in production — it was added in 3.11 and my current project targets 3.10. But based on how structured concurrency works, I'd expect it to handle exception grouping differently from gather..." Interviewers respect intellectual honesty paired with active reasoning. What they do not respect: silence, or confidently-delivered wrong answers.

A two-week preparation schedule for middle Python roles

  • Days 1–2: GIL, memory management, dunder methods. Write at least two custom classes that implement the data model — a vector class with full arithmetic support and a custom container that supports iteration and len().
  • Days 3–4: LEGB closures, the mutable default trap, decorators. Implement a working retry decorator, a timer decorator, and a simple memoize decorator from scratch without references first.
  • Days 5–6: MRO, __slots__, dataclasses. Build a two-level class hierarchy using only explicit descriptors — no @property shortcut. Then inspect the MRO manually with .__mro__.
  • Days 7–8: asyncio event loop, gather vs TaskGroup, the blocking code problem. Write an async scraper that fetches 10 URLs concurrently with aiohttp, proper error handling, and a per-request timeout.
  • Days 9–10: Dict internals, shallow vs deep copy, type hints and Protocols. Build a small typed data pipeline: define a Processor Protocol, implement two concrete processors, wire them together with full annotations that pass mypy.
  • Days 11–12: Q26–30. Trace through the cell object example without running it. Implement a context manager both ways. Write exception chains with raise from. Rewrite two if/elif chains as match/case.
  • Days 13–14: Two full mock sessions, spoken aloud, timed at 45 minutes each. A colleague is ideal; recording yourself works as a solo alternative. Cover any gaps that surface.

What different Python roles actually emphasize

  • Backend / API roles (FastAPI, Django, Flask): asyncio and the GIL (Q1, Q16–18), database access patterns, type hints and Pydantic models, decorator patterns (Q9), exception chaining (Q28).
  • Data engineering / ETL: Generator pipelines for large datasets (Q10), memory profiling (Q20), shallow vs deep copy (Q23), efficient data structures (Q21–22), multiprocessing for CPU-bound transformation (Q16).
  • ML infrastructure: The GIL and NumPy interactions (Q1), memory management and __slots__ for data records (Q2, Q13), asyncio for model-serving APIs (Q17–18).
  • Platform / DevTools engineering: Metaclasses and plugin systems (Q25), descriptors and framework internals (Q4), cell objects and closures (Q26), match/case for command parsing (Q30).

Frequently Asked Questions

Do Python interview questions differ significantly between companies?

Significantly. Product companies (startups, scale-ups) focus on practical patterns: async error handling, memory leaks, choosing the right concurrency primitive. FAANG-adjacent companies add algorithmic questions on top of these fundamentals. Data engineering roles test generator pipelines and large-data patterns. Read the job description carefully: "deep Python knowledge" means this guide; "data structures and algorithms" means LeetCode-style prep.

Is the GIL going away in Python 3.13+?

Python 3.13 introduced an experimental free-threaded build (PEP 703). It is opt-in and not the default. Most third-party C extensions — NumPy, Cython-based packages, many database drivers — are not yet thread-safe without the GIL. The pragmatic answer for 2026: understand the GIL deeply because the standard build still has it, and know that the free-threaded build exists but is not yet production-ready for most stacks.

Should I learn type hints before a Python interview?

Yes. Type hints are now standard in production Python code. You do not need to know every typing module export, but be comfortable with list[T], dict[K, V], T | None, Union, Callable, and the difference between Protocol and ABC. Interviewers at well-run companies write type-annotated code in their examples and expect you to read and extend it.

What Python version should I know for interviews?

Target Python 3.10+. Most companies have upgraded. Know what was added in 3.10 (structural pattern matching), 3.11 (TaskGroup, exception groups, significant performance improvements — up to 25% faster), and 3.12 (improved f-strings, type parameter syntax). Being able to say "this is a 3.11+ pattern" signals that you track the language actively.

How important are the asyncio questions for non-async roles?

Even if the role does not use asyncio directly, these questions test your understanding of concurrency concepts. An interviewer asking "what would happen if you called time.sleep() inside an async function?" is really asking whether you understand blocking vs non-blocking I/O. That conceptual distinction matters even in synchronous Django or Flask code — understanding why your batch jobs feel slow or why your mixed async/sync application degrades under load requires this foundation.

What is the most overlooked topic in Python interview prep?

The mutable default argument trap (Q7) and the closure late-binding trap (Q6) appear in interviews far more often than most candidates expect, because they are bugs that can hide in production code for months. Candidates who have actually hit these bugs give notably better answers. If you have not encountered them yet, deliberately write code that triggers each bug, observe it breaking, then fix it. First-hand experience with a bug is worth more than reading about it.

What Python concepts are most frequently tested in middle-level interviews?

Based on common patterns across Python roles: (1) The GIL and concurrency — threading vs multiprocessing vs asyncio appears in nearly every mid-level Python interview; (2) decorators — implementing a working decorator with functools.wraps is a standard live-coding question; (3) generators and yield from — lazy evaluation, memory efficiency, and iteration protocol; (4) the mutable default trap — almost universal for any role involving API design; (5) context managers — resource management via both __enter__/__exit__ and contextlib. Questions least likely for mid-level but standard for senior roles: metaclasses (Q25), cell objects and closure internals (Q26), and __new__ overrides (Q12).

What is a good Python coding question to practice for interviews?

Implement a decorator that retries on specified exceptions with exponential backoff, preserves the original function's __name__ and __doc__, and accepts arguments when applied. This single question tests decorators (Q9), functools.wraps, keyword-only parameters (Q8), exception handling (Q28), and whether you reach for time.sleep() vs asyncio.sleep() correctly depending on the context (Q16). It is more signal-dense than most algorithm questions for a Python role. A bonus question: make it work with both synchronous and async functions by detecting inspect.iscoroutinefunction(func).