gitGood.dev
Back to Blog

Top 50 Python Interview Questions (2026)

D
Dan
36 min read

Python interviews have changed. A few years ago, you could get by explaining list comprehensions and the difference between is and ==. Today, interviewers want to see that you understand the language deeply - how things actually work under the hood, not just the syntax.

Here are the 50 questions that actually come up, organized from fundamentals to the latest Python 3.12+ features. Each answer focuses on what interviewers are really looking for.

Core Language (1-10)

1. What's the difference between mutable and immutable types in Python?

Immutable types can't be changed after creation. Mutable types can.

Immutable: int, float, str, tuple, frozenset, bytes
Mutable: list, dict, set, bytearray

# Strings are immutable - this creates a NEW string
name = "hello"
name = name + " world"  # new object, old one gets garbage collected

# Lists are mutable - this modifies in place
items = [1, 2, 3]
items.append(4)  # same object, modified

Why this matters in practice: mutable default arguments are a classic Python trap.

# BUG: the default list is shared across all calls
def add_item(item, items=[]):
    items.append(item)
    return items

add_item(1)  # [1]
add_item(2)  # [1, 2] - not [2]!

# FIX: use None as default
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

The real interview answer: "Immutability affects default arguments, dictionary keys (only hashable/immutable types work), and thread safety. I use immutable types by default when I don't need mutation."

2. Explain the GIL. Why does it exist and what are its implications?

The Global Interpreter Lock (GIL) is a mutex that allows only one thread to execute Python bytecode at a time in CPython. It exists because CPython's memory management (reference counting) isn't thread-safe.

import threading
import time

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1  # NOT thread-safe even with the GIL

# This won't give you 2,000,000
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter)  # Some number less than 2,000,000

The real interview answer: "The GIL means threads don't help with CPU-bound work in CPython. For CPU-bound tasks, I use multiprocessing or concurrent.futures.ProcessPoolExecutor. For I/O-bound tasks, threads work fine because the GIL is released during I/O operations. And Python 3.13 introduced a free-threaded build that disables the GIL entirely."

3. What's the difference between a list and a tuple?

Lists are mutable, tuples are immutable. But the real differences go deeper.

# Tuples are hashable (if their contents are) - usable as dict keys
locations = {(40.7, -74.0): "NYC", (51.5, -0.1): "London"}

# Tuples are slightly faster and use less memory
import sys
sys.getsizeof([1, 2, 3])    # 88 bytes (typical)
sys.getsizeof((1, 2, 3))    # 64 bytes (typical)

# Tuples signal intent: "this data shouldn't change"
Point = tuple  # conceptually fixed structure
rgb = (255, 128, 0)  # always exactly 3 values

The real interview answer: "I use tuples for fixed collections - coordinates, RGB values, database rows. Lists are for collections that grow or shrink. Tuples also have a performance edge and can serve as dictionary keys."

4. How does deepcopy differ from copy?

copy.copy() creates a shallow copy - a new container with references to the same objects. copy.deepcopy() recursively copies everything.

import copy

original = [[1, 2], [3, 4]]

shallow = copy.copy(original)
shallow[0].append(99)
print(original)  # [[1, 2, 99], [3, 4]] - inner list was shared!

deep = copy.deepcopy(original)
deep[0].append(88)
print(original)  # [[1, 2, 99], [3, 4]] - completely independent

Watch out: deepcopy handles circular references, but it's slow for large object graphs. For simple flat structures, shallow copy is fine.

5. What are *args and **kwargs?

*args collects positional arguments into a tuple. **kwargs collects keyword arguments into a dictionary.

def log(message, *tags, **metadata):
    print(f"[{', '.join(tags)}] {message}")
    for key, value in metadata.items():
        print(f"  {key}: {value}")

log("Server started", "info", "startup", port=8080, env="prod")
# [info, startup] Server started
#   port: 8080
#   env: prod

The unpacking operators also work in reverse:

def greet(first, last, title=""):
    return f"{title} {first} {last}".strip()

args = ("Jane", "Doe")
kwargs = {"title": "Dr."}
greet(*args, **kwargs)  # "Dr. Jane Doe"

6. Explain list comprehensions vs generator expressions.

List comprehensions build the entire list in memory. Generator expressions produce values lazily, one at a time.

# List comprehension - builds entire list in memory
squares_list = [x**2 for x in range(1_000_000)]  # ~8MB of memory

# Generator expression - produces values on demand
squares_gen = (x**2 for x in range(1_000_000))  # negligible memory

# Use generators when you only need to iterate once
total = sum(x**2 for x in range(1_000_000))  # no list created

The real interview answer: "I reach for generators when dealing with large datasets or when I only need to iterate once. List comprehensions when I need random access or multiple passes through the data."

7. How do generators work internally?

Generators use yield to produce a sequence of values lazily. When a generator function is called, it returns a generator object without executing the function body. Each call to next() runs the function until the next yield.

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
[next(fib) for _ in range(10)]
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

You can also send values into generators:

def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

acc = accumulator()
next(acc)          # 0 (prime the generator)
acc.send(10)       # 10
acc.send(20)       # 30
acc.send(5)        # 35

Generators maintain their state between yield calls - that's what makes them powerful for pipelines, streaming data, and coroutines.

8. How do type hints work in Python?

Type hints are annotations that don't affect runtime behavior. They're checked by tools like mypy, pyright, or your IDE.

from typing import Optional

def find_user(user_id: int, include_deleted: bool = False) -> Optional[dict]:
    """Find a user by ID."""
    ...

# Modern Python (3.10+) - use union syntax
def find_user(user_id: int) -> dict | None:
    ...

# Generic types (3.9+ use built-in types)
def first_item(items: list[str]) -> str | None:
    return items[0] if items else None

# TypedDict for structured dicts
from typing import TypedDict

class UserProfile(TypedDict):
    name: str
    email: str
    age: int | None

The real interview answer: "Type hints are documentation that tooling can verify. I use them everywhere in production code - they catch bugs before runtime and make refactoring safe. But I don't fight the type system when it gets in the way."

9. What is the walrus operator (:=) and when do you use it?

The walrus operator (added in Python 3.8) assigns a value to a variable as part of an expression.

# Without walrus - you compute the value twice or use a temp variable
data = get_data()
if data:
    process(data)

# With walrus - assign and test in one step
if data := get_data():
    process(data)

# Great for while loops
while chunk := file.read(8192):
    process(chunk)

# Useful in list comprehensions with expensive computations
results = [
    cleaned
    for raw in data
    if (cleaned := expensive_clean(raw)) is not None
]

Don't overuse it. If the walrus operator makes code harder to read, use a regular assignment instead.

10. Explain match/case (structural pattern matching).

Added in Python 3.10, match/case goes way beyond a switch statement. It does structural pattern matching - it can destructure and match complex data.

def handle_command(command):
    match command.split():
        case ["quit"]:
            return "Goodbye"
        case ["go", direction]:
            return f"Moving {direction}"
        case ["pick", "up", item]:
            return f"Picked up {item}"
        case ["attack", target, "with", weapon]:
            return f"Attacking {target} with {weapon}"
        case _:
            return "Unknown command"

handle_command("go north")                # "Moving north"
handle_command("attack dragon with sword") # "Attacking dragon with sword"

You can match on types, guards, and nested structures:

def process_event(event):
    match event:
        case {"type": "click", "x": x, "y": y} if x > 0 and y > 0:
            return f"Click at ({x}, {y})"
        case {"type": "keypress", "key": str(key)}:
            return f"Key pressed: {key}"
        case {"type": "error", "code": int(code)} if code >= 500:
            return f"Server error: {code}"
        case _:
            return "Unhandled event"

Data Structures (11-18)

11. How do Python dictionaries work internally?

Python dicts use a hash table. When you insert a key, Python computes hash(key), maps it to an index in an internal array, and stores the key-value pair there. Collisions are resolved using open addressing (probing).

# Dicts maintain insertion order (guaranteed since Python 3.7)
d = {"banana": 2, "apple": 1, "cherry": 3}
list(d.keys())  # ['banana', 'apple', 'cherry']

# Keys must be hashable
valid = {(1, 2): "tuple key", "string": "str key", 42: "int key"}
# invalid = {[1, 2]: "list key"}  # TypeError: unhashable type: 'list'

The real interview answer: "Python dicts have O(1) average-case lookup and insertion. They're implemented as hash tables with open addressing. Since 3.6 they're compact (less memory than before) and since 3.7 they maintain insertion order. The hash table resizes when it's about two-thirds full."

12. When would you use a set over a list?

Sets give you O(1) membership testing, deduplication, and set operations (union, intersection, difference). Lists give you ordering and duplicates.

# Membership testing: set is O(1), list is O(n)
allowed_users = {"alice", "bob", "charlie"}  # use a set
if username in allowed_users:  # fast lookup
    grant_access()

# Deduplication
emails = ["a@b.com", "c@d.com", "a@b.com"]
unique_emails = list(set(emails))  # removes duplicates (but loses order)

# Preserve order while deduplicating
unique_ordered = list(dict.fromkeys(emails))

# Set operations
team_a = {"alice", "bob", "charlie"}
team_b = {"bob", "diana", "eve"}
team_a & team_b    # {'bob'} - intersection
team_a | team_b    # all five - union
team_a - team_b    # {'alice', 'charlie'} - difference

13. What is defaultdict and when do you use it?

defaultdict automatically creates missing keys with a default factory function, eliminating the need for key existence checks.

from collections import defaultdict

# Grouping items
words = ["apple", "banana", "avocado", "blueberry", "cherry"]
by_letter = defaultdict(list)
for word in words:
    by_letter[word[0]].append(word)
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}

# Counting (though Counter is better for this)
word_count = defaultdict(int)
for word in "the cat sat on the mat".split():
    word_count[word] += 1

# Nested defaults
tree = lambda: defaultdict(tree)
taxonomy = tree()
taxonomy["animal"]["mammal"]["dog"] = "woof"

14. How does Counter work and what are its best uses?

Counter is a dict subclass for counting hashable objects. It comes with useful methods that plain dicts don't have.

from collections import Counter

# Basic counting
inventory = Counter(["apple", "banana", "apple", "cherry", "banana", "apple"])
inventory.most_common(2)  # [('apple', 3), ('banana', 2)]

# Counter arithmetic
morning = Counter(coffee=3, tea=1)
afternoon = Counter(coffee=1, tea=4)
total = morning + afternoon  # Counter({'tea': 5, 'coffee': 4})

# Finding common elements
text1 = Counter("abracadabra")
text2 = Counter("abcdefg")
text1 & text2  # Counter({'a': 1, 'b': 1, 'c': 1})

# Practical use: check if one string is an anagram of another
def is_anagram(s1, s2):
    return Counter(s1.lower()) == Counter(s2.lower())

15. When would you use heapq?

heapq implements a min-heap on top of a regular list. Use it when you need the smallest (or largest) elements efficiently without sorting the entire collection.

import heapq

# Find the top N items without sorting everything
scores = [85, 92, 78, 95, 88, 76, 99, 82]
top_3 = heapq.nlargest(3, scores)     # [99, 95, 92]
bottom_3 = heapq.nsmallest(3, scores)  # [76, 78, 82]

# Priority queue pattern
tasks = []
heapq.heappush(tasks, (1, "critical bug"))
heapq.heappush(tasks, (3, "nice to have"))
heapq.heappush(tasks, (2, "feature request"))

heapq.heappop(tasks)  # (1, 'critical bug') - lowest priority number first

The real interview answer: "Use heapq when you need a priority queue or when you need the top/bottom K elements from a large collection. nlargest and nsmallest are more efficient than sorting when K is much smaller than N."

16. What is deque and when is it better than a list?

deque (double-ended queue) provides O(1) append and pop from both ends. Lists are O(n) for operations at the front.

from collections import deque

# Sliding window / fixed-size buffer
recent_temps = deque(maxlen=5)
for temp in [72, 75, 71, 73, 78, 80, 82]:
    recent_temps.append(temp)
print(recent_temps)  # deque([73, 78, 80, 82], maxlen=5) - oldest dropped

# BFS traversal
def bfs(graph, start):
    visited = set()
    queue = deque([start])
    while queue:
        node = queue.popleft()  # O(1) - list.pop(0) would be O(n)
        if node not in visited:
            visited.add(node)
            queue.extend(graph[node])
    return visited

# Rotate elements
d = deque([1, 2, 3, 4, 5])
d.rotate(2)   # deque([4, 5, 1, 2, 3])
d.rotate(-1)  # deque([5, 1, 2, 3, 4])

17. What's a namedtuple and how does it compare to a regular tuple?

namedtuple creates lightweight, immutable classes with named fields. It's a tuple that you can access by name instead of index.

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y)      # 3 4
print(p[0], p[1])     # 3 4 - tuple indexing still works
x, y = p              # unpacking still works

# With defaults (Python 3.6.1+)
Config = namedtuple("Config", ["host", "port", "debug"], defaults=["localhost", 8080, False])
Config()  # Config(host='localhost', port=8080, debug=False)

In modern Python, dataclasses have largely replaced namedtuple for new code, but named tuples are still great when you want immutability and tuple compatibility (like using them as dict keys).

18. When should you use dataclasses vs namedtuple vs a regular class?

from dataclasses import dataclass, field
from collections import namedtuple

# namedtuple: immutable, lightweight, tuple-compatible
Coord = namedtuple("Coord", ["lat", "lng"])

# dataclass: mutable by default, full-featured, modern Python
@dataclass
class User:
    name: str
    email: str
    age: int
    tags: list[str] = field(default_factory=list)

    def display_name(self) -> str:
        return f"{self.name} ({self.email})"

# Frozen dataclass: immutable, hashable
@dataclass(frozen=True)
class CacheKey:
    endpoint: str
    params: tuple

# Dataclass with slots (Python 3.10+) - faster attribute access, less memory
@dataclass(slots=True)
class SensorReading:
    timestamp: float
    value: float
    sensor_id: str

The real interview answer: "I use dataclass for most structured data - it gives you __init__, __repr__, __eq__ for free and supports mutability, defaults, and ordering. I use namedtuple when I specifically need tuple behavior or immutability. Regular classes for complex behavior with lots of methods."

Functions & OOP (19-28)

19. How do decorators work?

A decorator is a function that takes a function and returns a modified version of it. The @decorator syntax is just syntactic sugar.

import functools
import time

def timer(func):
    @functools.wraps(func)  # preserves the original function's name/docstring
    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():
    time.sleep(1)

slow_function()  # "slow_function took 1.0012s"

Decorators with arguments require an extra level of nesting:

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5)
def flaky_api_call():
    ...

Always use functools.wraps - without it, the wrapped function loses its name and docstring.

20. What are closures and how are they used in Python?

A closure is a function that captures variables from its enclosing scope. The inner function "remembers" the environment it was created in.

def make_multiplier(factor):
    def multiply(n):
        return n * factor  # 'factor' is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
double(5)   # 10
triple(5)   # 15

# The captured variable persists
def make_counter():
    count = 0
    def increment():
        nonlocal count  # needed to modify the captured variable
        count += 1
        return count
    return increment

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

The nonlocal keyword is important - without it, assigning to count inside the inner function would create a new local variable instead of modifying the captured one.

21. Explain dunder (magic) methods with examples.

Dunder methods let your objects work with Python's built-in operations and syntax.

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount}, '{self.currency}')"

    def __str__(self):
        return f"${self.amount:.2f} {self.currency}"

    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Can't add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def __eq__(self, other):
        return self.amount == other.amount and self.currency == other.currency

    def __lt__(self, other):
        if self.currency != other.currency:
            raise ValueError("Can't compare different currencies")
        return self.amount < other.amount

    def __bool__(self):
        return self.amount != 0

price = Money(9.99)
tax = Money(0.80)
total = price + tax     # Money(10.79, 'USD')
print(total)            # $10.79 USD
bool(Money(0))          # False

Key ones to know: __init__, __repr__, __str__, __eq__, __hash__, __len__, __getitem__, __iter__, __call__, __enter__/__exit__.

22. How does Method Resolution Order (MRO) work?

MRO determines the order Python searches for methods in a class hierarchy, using the C3 linearization algorithm.

class A:
    def greet(self):
        return "A"

class B(A):
    def greet(self):
        return "B"

class C(A):
    def greet(self):
        return "C"

class D(B, C):
    pass

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

D().greet()  # "B" - B comes before C in MRO

The MRO guarantees that: (1) subclasses come before parent classes, (2) the order of bases is preserved, and (3) each class appears only once. super() follows the MRO, which is why it works correctly with multiple inheritance.

23. What are abstract classes and when do you use them?

Abstract classes define an interface that subclasses must implement. You can't instantiate an abstract class directly.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def charge(self, amount: float) -> bool:
        """Process a payment. Must be implemented by subclasses."""
        ...

    @abstractmethod
    def refund(self, transaction_id: str) -> bool:
        ...

    def validate_amount(self, amount: float) -> bool:
        """Concrete method - shared by all subclasses."""
        return amount > 0

class StripeProcessor(PaymentProcessor):
    def charge(self, amount: float) -> bool:
        # Stripe-specific implementation
        return True

    def refund(self, transaction_id: str) -> bool:
        return True

# PaymentProcessor()  # TypeError: Can't instantiate abstract class
processor = StripeProcessor()  # Works

24. How does @property work?

@property lets you define methods that behave like attributes - giving you getters, setters, and deleters with a clean API.

class Temperature:
    def __init__(self, celsius: float):
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value: float):
        self.celsius = (value - 32) * 5/9

temp = Temperature(100)
temp.fahrenheit        # 212.0
temp.fahrenheit = 32   # sets celsius to 0
temp.celsius           # 0.0

The real interview answer: "Properties let you start with simple attributes and add validation or computation later without changing the API. That's why Python doesn't need Java-style getters and setters."

25. What are __slots__ and why would you use them?

__slots__ restricts which attributes an instance can have, replacing the per-instance __dict__ with a fixed set of slots. This saves memory and makes attribute access slightly faster.

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

class PointWithSlots:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

import sys
p1 = PointWithDict(1, 2)
p2 = PointWithSlots(1, 2)
sys.getsizeof(p1.__dict__)  # 104 bytes (the instance dict)
# p2 has no __dict__ - saves ~100 bytes per instance

p2.z = 3  # AttributeError: 'PointWithSlots' object has no attribute 'z'

Use __slots__ when you're creating millions of instances of a class and memory matters. For most code, don't bother - the flexibility of __dict__ is more valuable.

26. What are metaclasses?

A metaclass is the class of a class. Just as an object is an instance of a class, a class is an instance of its metaclass. The default metaclass is type.

# type is the metaclass of all classes
print(type(int))       # <class 'type'>
print(type(str))       # <class 'type'>

# Custom metaclass: enforce that all methods have docstrings
class DocumentedMeta(type):
    def __new__(mcs, name, bases, namespace):
        for attr_name, attr_value in namespace.items():
            if callable(attr_value) and not attr_name.startswith('_'):
                if not attr_value.__doc__:
                    raise TypeError(
                        f"Method {attr_name} in {name} must have a docstring"
                    )
        return super().__new__(mcs, name, bases, namespace)

class MyAPI(metaclass=DocumentedMeta):
    def get_users(self):
        """Fetch all users."""
        pass

    def delete_user(self):  # TypeError: must have a docstring
        pass

The real interview answer: "Metaclasses are powerful but rarely needed. Most of the time, decorators or __init_subclass__ can achieve the same thing with less complexity. I'd use a metaclass for frameworks or ORMs where you need to control class creation across a hierarchy."

27. What are Protocols (structural subtyping)?

Protocols (from typing, Python 3.8+) define interfaces through structure rather than inheritance. If an object has the right methods, it satisfies the Protocol - no inheritance needed.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self, x: int, y: int) -> None: ...

class Circle:
    def draw(self, x: int, y: int) -> None:
        print(f"Drawing circle at ({x}, {y})")

class Square:
    def draw(self, x: int, y: int) -> None:
        print(f"Drawing square at ({x}, {y})")

def render(shape: Drawable, x: int, y: int) -> None:
    shape.draw(x, y)

# Both work - no inheritance from Drawable required
render(Circle(), 10, 20)
render(Square(), 30, 40)

# Runtime checking works with @runtime_checkable
isinstance(Circle(), Drawable)  # True

Protocols are Python's version of duck typing with static type checking. They're how you should define interfaces in modern Python unless you need shared implementation (then use ABC).

28. What are descriptors?

Descriptors are objects that define __get__, __set__, or __delete__ methods. They control attribute access on the class level - property, classmethod, and staticmethod are all implemented as descriptors.

class Validated:
    """A descriptor that validates values on assignment."""
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, f'_{self.name}', None)

    def __set__(self, obj, value):
        if self.min_value is not None and value < self.min_value:
            raise ValueError(f"{self.name} must be >= {self.min_value}")
        if self.max_value is not None and value > self.max_value:
            raise ValueError(f"{self.name} must be <= {self.max_value}")
        setattr(obj, f'_{self.name}', value)

class Product:
    price = Validated(min_value=0)
    quantity = Validated(min_value=0, max_value=10000)

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity

p = Product("Widget", 9.99, 100)
p.price = -1  # ValueError: price must be >= 0

Descriptors are what make @property, @classmethod, and @staticmethod work under the hood. Understanding them means understanding Python's attribute lookup mechanism.

Error Handling & Testing (29-34)

29. Explain Python's exception hierarchy.

All exceptions inherit from BaseException. Most exceptions you'll catch inherit from Exception. SystemExit, KeyboardInterrupt, and GeneratorExit inherit directly from BaseException - you almost never want to catch these.

# BaseException
#   +-- SystemExit
#   +-- KeyboardInterrupt
#   +-- GeneratorExit
#   +-- Exception
#       +-- ValueError
#       +-- TypeError
#       +-- KeyError
#       +-- IndexError
#       +-- FileNotFoundError (OSError subclass)
#       +-- ConnectionError (OSError subclass)
#       +-- ...

# WRONG - catches KeyboardInterrupt and SystemExit
try:
    dangerous_operation()
except:  # bare except
    pass

# WRONG - still too broad
try:
    dangerous_operation()
except BaseException:
    pass

# RIGHT - catch specific exceptions
try:
    result = data[key]
except KeyError:
    result = default_value

The real interview answer: "Always catch the most specific exception possible. Never use bare except: or catch BaseException. Use except Exception as the broadest reasonable catch, and even that should usually log the error."

30. How do context managers work?

Context managers handle setup and teardown using with statements. They guarantee cleanup even if exceptions occur.

# Class-based context manager
class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    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:
    time.sleep(0.5)
# "Elapsed: 0.5003s"

# Generator-based context manager (simpler)
from contextlib import contextmanager

@contextmanager
def temp_directory():
    import tempfile
    import shutil
    dirpath = tempfile.mkdtemp()
    try:
        yield dirpath
    finally:
        shutil.rmtree(dirpath)

with temp_directory() as tmpdir:
    # tmpdir exists here
    pass
# tmpdir is deleted here, even if an exception occurred

The __exit__ method receives exception info. If it returns True, the exception is suppressed. If False (or None), the exception propagates normally.

31. How do you structure tests with pytest?

pytest uses plain functions and assert statements - no class boilerplate needed.

# test_calculator.py
import pytest
from calculator import Calculator

def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_divide():
    calc = Calculator()
    assert calc.divide(10, 3) == pytest.approx(3.333, rel=1e-3)

def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(1, 0)

# Fixtures for shared setup
@pytest.fixture
def calc():
    return Calculator()

def test_with_fixture(calc):
    assert calc.add(1, 1) == 2

# Parametrize for multiple test cases
@pytest.mark.parametrize("a, b, expected", [
    (1, 1, 2),
    (-1, 1, 0),
    (0, 0, 0),
    (100, -100, 0),
])
def test_add_parametrized(calc, a, b, expected):
    assert calc.add(a, b) == expected

32. How do you mock dependencies in Python tests?

unittest.mock lets you replace real objects with fake ones during testing.

from unittest.mock import patch, MagicMock, AsyncMock
import pytest

class UserService:
    def __init__(self, db, email_client):
        self.db = db
        self.email_client = email_client

    def create_user(self, email):
        user = self.db.insert({"email": email})
        self.email_client.send_welcome(email)
        return user

# Mock via patch decorator
@patch("myapp.services.EmailClient")
@patch("myapp.services.Database")
def test_create_user(mock_db, mock_email):
    mock_db.insert.return_value = {"id": 1, "email": "test@test.com"}

    service = UserService(mock_db, mock_email)
    user = service.create_user("test@test.com")

    mock_db.insert.assert_called_once()
    mock_email.send_welcome.assert_called_once_with("test@test.com")

# Mock via fixture (often cleaner)
@pytest.fixture
def user_service():
    db = MagicMock()
    email = MagicMock()
    db.insert.return_value = {"id": 1, "email": "test@test.com"}
    return UserService(db, email)

def test_create_user_fixture(user_service):
    user_service.create_user("test@test.com")
    user_service.email_client.send_welcome.assert_called_once()

33. How do you create custom exceptions?

Create a hierarchy that matches your domain. Always inherit from Exception, not BaseException.

class AppError(Exception):
    """Base exception for the application."""
    def __init__(self, message: str, code: str = "UNKNOWN"):
        self.message = message
        self.code = code
        super().__init__(self.message)

class ValidationError(AppError):
    """Raised when input validation fails."""
    def __init__(self, field: str, message: str):
        self.field = field
        super().__init__(message, code="VALIDATION_ERROR")

class NotFoundError(AppError):
    """Raised when a requested resource doesn't exist."""
    def __init__(self, resource: str, resource_id: str):
        self.resource = resource
        self.resource_id = resource_id
        super().__init__(
            f"{resource} with id '{resource_id}' not found",
            code="NOT_FOUND"
        )

# Usage
try:
    raise NotFoundError("User", "abc-123")
except NotFoundError as e:
    print(e.code)        # NOT_FOUND
    print(e.resource)    # User
    print(e.message)     # User with id 'abc-123' not found
except AppError as e:
    print(f"App error [{e.code}]: {e.message}")

34. What's the deal with assertions in Python?

assert statements are debugging aids, not error handling. They can be disabled entirely with python -O (optimized mode).

# Good: assertions for debugging and development checks
def calculate_discount(price, discount_pct):
    assert 0 <= discount_pct <= 100, f"Invalid discount: {discount_pct}"
    return price * (1 - discount_pct / 100)

# BAD: never use assert for validation of user input
def process_payment(amount):
    assert amount > 0  # This disappears with python -O!
    # Use a proper check instead:
    if amount <= 0:
        raise ValueError("Payment amount must be positive")

# Good: assertions in tests (pytest rewrites these for better messages)
def test_something():
    result = compute()
    assert result == expected, f"Got {result}, expected {expected}"

The real interview answer: "Assertions are for catching programmer errors during development, not for validating data at runtime. They can be globally disabled, so never use them for security checks or input validation."

Async & Concurrency (35-40)

35. How does asyncio work?

asyncio provides cooperative multitasking using an event loop. async functions (coroutines) use await to yield control back to the event loop while waiting for I/O.

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        return await asyncio.gather(*tasks)

# Fetch 100 URLs concurrently - not sequentially
urls = [f"https://api.example.com/item/{i}" for i in range(100)]
results = asyncio.run(fetch_all(urls))

Key concepts: async def creates a coroutine, await suspends execution until the result is ready, asyncio.gather() runs multiple coroutines concurrently, and asyncio.run() starts the event loop.

36. Threads vs processes - when do you use each?

Threads share memory and are good for I/O-bound tasks. The GIL prevents parallel CPU execution but is released during I/O.

Processes have separate memory spaces and bypass the GIL. Good for CPU-bound tasks.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests
import math

# I/O-bound - use threads
def fetch(url):
    return requests.get(url).status_code

urls = ["https://example.com"] * 20
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(fetch, urls))

# CPU-bound - use processes
def heavy_math(n):
    return sum(math.factorial(i) for i in range(n))

numbers = [500, 600, 700, 800]
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(heavy_math, numbers))

The real interview answer: "I/O-bound means threads or asyncio. CPU-bound means multiprocessing. asyncio is the best choice for high-concurrency I/O (thousands of connections), threads for simpler I/O concurrency, and processes for CPU-heavy work."

37. What are practical GIL workarounds?

Beyond threads and processes, there are several ways to get true parallelism in Python:

# 1. multiprocessing - separate processes, no GIL sharing
from multiprocessing import Pool

with Pool(4) as pool:
    results = pool.map(cpu_heavy_function, data)

# 2. C extensions - NumPy releases the GIL during computation
import numpy as np
# This runs in parallel on multiple cores internally
result = np.dot(large_matrix_a, large_matrix_b)

# 3. concurrent.futures - clean API for both threads and processes
from concurrent.futures import ProcessPoolExecutor, as_completed

with ProcessPoolExecutor() as executor:
    futures = {executor.submit(process, item): item for item in items}
    for future in as_completed(futures):
        item = futures[future]
        result = future.result()

# 4. Python 3.13+ free-threaded build (experimental)
# Run with: python3.13t script.py
# No GIL - true thread parallelism

38. How does concurrent.futures simplify concurrent code?

concurrent.futures provides a unified API for both thread-based and process-based parallelism.

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def process_item(item):
    time.sleep(0.1)  # simulate work
    return item * 2

items = range(20)

# Submit all tasks and process results as they complete
with ThreadPoolExecutor(max_workers=5) as executor:
    future_to_item = {
        executor.submit(process_item, item): item
        for item in items
    }

    for future in as_completed(future_to_item):
        item = future_to_item[future]
        try:
            result = future.result()
            print(f"Item {item} -> {result}")
        except Exception as e:
            print(f"Item {item} failed: {e}")

# Even simpler with executor.map (ordered results)
with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(process_item, items))

39. What are async generators?

Async generators combine async and yield - they produce values asynchronously, one at a time.

import asyncio
import aiohttp

async def fetch_pages(base_url, max_pages=10):
    """Async generator that yields pages of results."""
    async with aiohttp.ClientSession() as session:
        for page in range(1, max_pages + 1):
            url = f"{base_url}?page={page}"
            async with session.get(url) as response:
                data = await response.json()
                if not data["results"]:
                    return  # stop iteration
                yield data["results"]

# Consume with async for
async def get_all_users():
    all_users = []
    async for page in fetch_pages("https://api.example.com/users"):
        all_users.extend(page)
        if len(all_users) >= 100:
            break
    return all_users

Async generators are perfect for paginated APIs, streaming data, and any async data source where you don't want to load everything into memory at once.

40. How does multiprocessing handle shared state?

Processes have separate memory spaces, so sharing state requires explicit mechanisms.

from multiprocessing import Process, Value, Array, Queue, Manager

# Shared primitive values (with locking)
counter = Value('i', 0)  # 'i' = integer
def increment(counter, n):
    for _ in range(n):
        with counter.get_lock():
            counter.value += 1

# Shared arrays
shared_arr = Array('d', [0.0, 0.0, 0.0])  # 'd' = double

# Queue for passing messages between processes
def worker(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Processing: {item}")

q = Queue()
p = Process(target=worker, args=(q,))
p.start()
q.put("task 1")
q.put("task 2")
q.put(None)  # poison pill to stop the worker
p.join()

# Manager for complex shared data structures
with Manager() as manager:
    shared_dict = manager.dict()
    shared_list = manager.list()
    # These can be safely shared across processes

The real interview answer: "Prefer message passing (queues, pipes) over shared state. If you must share state, use Value/Array with locks or a Manager. Shared state across processes is the same headache as any concurrent programming - minimize it."

Advanced Topics (41-46)

41. How does Python manage memory?

Python uses a private heap for all objects. Memory management has three layers:

  1. Reference counting - each object tracks how many references point to it. When it hits zero, the object is immediately deallocated.
  2. Garbage collector - handles circular references that reference counting can't catch.
  3. Memory allocator (pymalloc) - optimized allocator for small objects (< 512 bytes).
import sys

a = [1, 2, 3]
print(sys.getrefcount(a))  # 2 (a + the getrefcount argument)

b = a
print(sys.getrefcount(a))  # 3

del b
print(sys.getrefcount(a))  # 2

# Circular reference - reference counting alone can't handle this
class Node:
    def __init__(self):
        self.parent = None
        self.children = []

parent = Node()
child = Node()
parent.children.append(child)
child.parent = parent
# Even after del parent, del child - the cycle keeps both alive
# The garbage collector detects and cleans these up

42. How does garbage collection work in CPython?

CPython uses generational garbage collection on top of reference counting. Objects are divided into three generations (0, 1, 2). New objects start in generation 0, and surviving objects get promoted to older generations which are collected less frequently.

import gc

# Check GC stats
print(gc.get_stats())
# [{'collections': 95, 'collected': 2436, 'uncollectable': 0}, ...]

# Force a collection
gc.collect()

# Disable GC (sometimes done for performance-critical code)
gc.disable()
# ... performance-critical section ...
gc.enable()

# Debug GC issues
gc.set_debug(gc.DEBUG_LEAK)

# Weak references - don't prevent garbage collection
import weakref

class Cache:
    def __init__(self):
        self._cache = weakref.WeakValueDictionary()

    def get(self, key, factory):
        if key not in self._cache:
            self._cache[key] = factory()
        return self._cache[key]

43. When would you use C extensions or ctypes?

When you need performance beyond what pure Python can deliver, especially for numerical computation or interfacing with C libraries.

# ctypes - call C libraries directly
import ctypes

# Load a shared library
libc = ctypes.CDLL("libc.so.6")
libc.printf(b"Hello from C! %d\n", 42)

# Call a custom C function
# Assuming you have libfast.so with: int sum_array(int* arr, int n)
libfast = ctypes.CDLL("./libfast.so")
arr = (ctypes.c_int * 5)(1, 2, 3, 4, 5)
result = libfast.sum_array(arr, 5)

# cffi - cleaner alternative to ctypes
from cffi import FFI
ffi = FFI()
ffi.cdef("double sqrt(double x);")
lib = ffi.dlopen("libm.so.6")
print(lib.sqrt(2.0))  # 1.4142...

For new projects, consider pybind11 for C++ or cffi for C. ctypes works but is more error-prone.

44. What is Cython and when would you use it?

Cython compiles Python-like code to C, giving you C performance while writing mostly Python syntax. It's ideal for computational bottlenecks.

# fibonacci.pyx (Cython file)
def fib_python(int n):
    """Regular Python speed."""
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

# With Cython type declarations - 100x faster
cpdef long fib_cython(int n):
    cdef long a = 0, b = 1
    cdef int i
    for i in range(n):
        a, b = b, a + b
    return a

Use Cython when you've profiled your code and found a specific bottleneck. Don't Cython-ize everything - just the hot path.

45. How do you profile Python code?

# cProfile - built-in profiler
import cProfile

cProfile.run('my_function()', sort='cumulative')

# Line-by-line profiling with line_profiler
# pip install line_profiler
# @profile decorator, then run: kernprof -l -v script.py

# Memory profiling
# pip install memory_profiler
from memory_profiler import profile

@profile
def memory_hungry():
    big_list = [i ** 2 for i in range(1_000_000)]
    return sum(big_list)

# timeit for micro-benchmarks
import timeit

# Compare two approaches
time_list = timeit.timeit(
    '[x**2 for x in range(1000)]',
    number=10000
)
time_map = timeit.timeit(
    'list(map(lambda x: x**2, range(1000)))',
    number=10000
)
print(f"List comp: {time_list:.3f}s, map: {time_map:.3f}s")

# py-spy for production profiling (no code changes needed)
# pip install py-spy
# py-spy top --pid 12345
# py-spy record -o profile.svg --pid 12345

The real interview answer: "I start with cProfile to find which functions are slow, then use line_profiler to find which lines within those functions are the bottleneck. py-spy is great for profiling running production processes without restarting them."

46. How do __slots__ affect memory and performance at scale?

We covered __slots__ earlier, but at scale the impact is significant. Here's a realistic benchmark:

import sys
from dataclasses import dataclass

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

class SlottedPoint:
    __slots__ = ('x', 'y', 'z')
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

@dataclass(slots=True)
class DataclassPoint:
    x: float
    y: float
    z: float

# Memory comparison with 1 million instances
regular = [RegularPoint(i, i, i) for i in range(1_000_000)]
slotted = [SlottedPoint(i, i, i) for i in range(1_000_000)]

# Regular: ~200MB (each instance has a __dict__)
# Slotted: ~72MB (no __dict__, fixed attribute storage)
# That's a ~64% memory reduction

The tradeoff: no dynamic attributes, no __dict__, and some complications with multiple inheritance. But for data-heavy applications - game entities, sensor readings, database rows - __slots__ is a significant win.

Modern Python 3.12+ (47-50)

47. What's new in Python 3.12 and 3.13?

Python 3.12 and 3.13 brought major improvements:

Better error messages (3.12+):

# Before: "SyntaxError: invalid syntax"
# Now: actually helpful messages like
# "SyntaxError: expected ':'" with a caret pointing to the exact position

Type parameter syntax (3.12) - PEP 695:

# Old way
from typing import TypeVar, Generic
T = TypeVar('T')

class Stack(Generic[T]):
    def push(self, item: T) -> None: ...
    def pop(self) -> T: ...

# New way (3.12+)
class Stack[T]:
    def push(self, item: T) -> None: ...
    def pop(self) -> T: ...

# Works for functions too
def first[T](items: list[T]) -> T:
    return items[0]

# Type aliases
type Vector = list[float]
type Matrix[T] = list[list[T]]

Per-interpreter GIL (3.12):

# Each sub-interpreter gets its own GIL
# Enables true parallelism without multiprocessing overhead
# Still experimental but a big step toward GIL removal

Free-threaded CPython (3.13 - experimental):

# Build Python without the GIL
# python3.13t script.py
# True multi-threaded parallelism for CPU-bound code
# Still experimental - some C extensions may not work yet

48. What typing improvements matter most for interview code?

Modern Python typing is much cleaner than it was even two years ago.

# Union syntax with | (3.10+)
def parse(value: str | int | None) -> dict:
    ...

# Self type (3.11+)
from typing import Self

class Builder:
    def set_name(self, name: str) -> Self:
        self.name = name
        return self

    def set_age(self, age: int) -> Self:
        self.age = age
        return self

# TypeGuard for narrowing (3.10+)
from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)

def process(items: list[object]):
    if is_string_list(items):
        # type checker knows items is list[str] here
        print(" ".join(items))

# TypeVarTuple for variadic generics (3.11+)
from typing import TypeVarTuple, Unpack

Ts = TypeVarTuple('Ts')

def apply_all(funcs: tuple[Unpack[Ts]], value: int):
    ...

# override decorator (3.12+)
from typing import override

class Parent:
    def greet(self) -> str:
        return "hello"

class Child(Parent):
    @override
    def greet(self) -> str:  # type checker verifies this actually overrides
        return "hi"

    @override
    def greet_typo(self) -> str:  # type checker error - no such method to override
        return "hey"

49. What performance improvements came in Python 3.11-3.13?

Python 3.11 was the "faster CPython" release, and 3.12-3.13 continued the trend.

# 3.11: Specializing adaptive interpreter
# CPython now specializes bytecode based on runtime types
# Result: 10-60% faster for most workloads

# 3.11: Zero-cost exception handling
# try/except blocks have zero overhead when no exception is raised
# This means you should use EAFP without guilt:
try:
    value = cache[key]       # fast path - no overhead
except KeyError:
    value = compute(key)     # only pays cost when exception occurs

# 3.11: Faster startup
# Frozen imports and other optimizations = ~15% faster startup

# 3.12: Comprehension inlining
# List/dict/set comprehensions no longer create a separate frame
# Result: comprehensions are faster than ever

# 3.12: More efficient memory usage for objects

# 3.13: Free-threaded build (experimental)
# Removes the GIL entirely for true thread parallelism
# Still has some overhead for reference counting (biased ref counting)

# Practical takeaway: just upgrading to 3.12+ gives you free performance
import sys
print(sys.version)  # make sure you're on 3.12+ in production

The real interview answer: "Upgrading from 3.10 to 3.12+ can give you 20-40% speedup for free with no code changes. The biggest wins came from the specializing adaptive interpreter in 3.11 and comprehension inlining in 3.12."

50. Show advanced structural pattern matching beyond basic match/case.

Pattern matching in Python goes far beyond simple value matching. Here's what makes it powerful:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

@dataclass
class Circle:
    center: Point
    radius: float

@dataclass
class Rectangle:
    top_left: Point
    bottom_right: Point

def describe_shape(shape):
    match shape:
        # Match and destructure nested dataclasses
        case Circle(center=Point(x=0, y=0), radius=r):
            return f"Circle at origin with radius {r}"

        case Circle(center=Point(x=x, y=y), radius=r) if r > 100:
            return f"Large circle at ({x}, {y})"

        case Rectangle(
            top_left=Point(x=x1, y=y1),
            bottom_right=Point(x=x2, y=y2)
        ) if x1 == 0 and y1 == 0:
            return f"Rectangle from origin to ({x2}, {y2})"

        case Circle() | Rectangle():
            return "Some shape"

# Pattern matching with sequences
def analyze_data(records):
    match records:
        case []:
            return "No data"
        case [single]:
            return f"Single record: {single}"
        case [first, *rest] if len(rest) > 100:
            return f"Large dataset starting with {first}"
        case [first, second, *_]:
            return f"Multiple records: {first}, {second}, ..."

# Matching against mappings with ** capture
def handle_response(response):
    match response:
        case {"status": 200, "data": {"users": [first, *rest]}}:
            return f"Got {len(rest) + 1} users, first: {first}"
        case {"status": 404}:
            return "Not found"
        case {"status": int(code), **extra} if code >= 500:
            return f"Server error {code}: {extra}"

Pattern matching shines in parsers, protocol handlers, state machines, and anywhere you need to destructure complex nested data. It's not just a switch statement - it's Python's answer to algebraic data types and exhaustive pattern matching.


Python interviews reward depth over breadth. You don't need to memorize every corner of the standard library, but you do need to understand how the language actually works - from the GIL to descriptors to memory management. Master these 50 questions and you'll handle whatever gets thrown at you.