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
name = "hello"
name = name + " world"
items = [1, 2, 3]
items.append(4)
Why this matters in practice: mutable default arguments are a classic Python trap.
def add_item(item, items=[]):
items.append(item)
return items
add_item(1)
add_item(2)
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
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter)
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.
locations = {(40.7, -74.0): "NYC", (51.5, -0.1): "London"}
import sys
sys.getsizeof([1, 2, 3])
sys.getsizeof((1, 2, 3))
Point = tuple
rgb = (255, 128, 0)
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)
deep = copy.deepcopy(original)
deep[0].append(88)
print(original)
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")
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)
6. Explain list comprehensions vs generator expressions.
List comprehensions build the entire list in memory. Generator expressions produce values lazily, one at a time.
squares_list = [x**2 for x in range(1_000_000)]
squares_gen = (x**2 for x in range(1_000_000))
total = sum(x**2 for x in range(1_000_000))
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)]
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)
acc.send(10)
acc.send(20)
acc.send(5)
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."""
...
def find_user(user_id: int) -> dict | None:
...
def first_item(items: list[str]) -> str | None:
return items[0] if items else None
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.
data = get_data()
if data:
process(data)
if data := get_data():
process(data)
while chunk := file.read(8192):
process(chunk)
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")
handle_command("attack 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).
d = {"banana": 2, "apple": 1, "cherry": 3}
list(d.keys())
valid = {(1, 2): "tuple key", "string": "str key", 42: "int key"}
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.
allowed_users = {"alice", "bob", "charlie"}
if username in allowed_users:
grant_access()
emails = ["a@b.com", "c@d.com", "a@b.com"]
unique_emails = list(set(emails))
unique_ordered = list(dict.fromkeys(emails))
team_a = {"alice", "bob", "charlie"}
team_b = {"bob", "diana", "eve"}
team_a & team_b
team_a | team_b
team_a - team_b
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
words = ["apple", "banana", "avocado", "blueberry", "cherry"]
by_letter = defaultdict(list)
for word in words:
by_letter[word[0]].append(word)
word_count = defaultdict(int)
for word in "the cat sat on the mat".split():
word_count[word] += 1
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
inventory = Counter(["apple", "banana", "apple", "cherry", "banana", "apple"])
inventory.most_common(2)
morning = Counter(coffee=3, tea=1)
afternoon = Counter(coffee=1, tea=4)
total = morning + afternoon
text1 = Counter("abracadabra")
text2 = Counter("abcdefg")
text1 & text2
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
scores = [85, 92, 78, 95, 88, 76, 99, 82]
top_3 = heapq.nlargest(3, scores)
bottom_3 = heapq.nsmallest(3, scores)
tasks = []
heapq.heappush(tasks, (1, "critical bug"))
heapq.heappush(tasks, (3, "nice to have"))
heapq.heappush(tasks, (2, "feature request"))
heapq.heappop(tasks)
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
recent_temps = deque(maxlen=5)
for temp in [72, 75, 71, 73, 78, 80, 82]:
recent_temps.append(temp)
print(recent_temps)
def bfs(graph, start):
visited = set()
queue = deque([start])
while queue:
node = queue.popleft()
if node not in visited:
visited.add(node)
queue.extend(graph[node])
return visited
d = deque([1, 2, 3, 4, 5])
d.rotate(2)
d.rotate(-1)
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)
print(p[0], p[1])
x, y = p
Config = namedtuple("Config", ["host", "port", "debug"], defaults=["localhost", 8080, False])
Config()
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
Coord = namedtuple("Coord", ["lat", "lng"])
@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})"
@dataclass(frozen=True)
class CacheKey:
endpoint: str
params: tuple
@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)
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()
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
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
double(5)
triple(5)
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
counter()
counter()
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
print(total)
bool(Money(0))
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__)
D().greet()
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:
return True
def refund(self, transaction_id: str) -> bool:
return True
processor = StripeProcessor()
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
temp.fahrenheit = 32
temp.celsius
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__)
p2.z = 3
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.
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.
print(type(int))
print(type(str))
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):
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)
render(Circle(), 10, 20)
render(Square(), 30, 40)
isinstance(Circle(), Drawable)
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
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.
try:
dangerous_operation()
except:
pass
try:
dangerous_operation()
except BaseException:
pass
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 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
with Timer() as t:
time.sleep(0.5)
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:
pass
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.
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)
@pytest.fixture
def calc():
return Calculator()
def test_with_fixture(calc):
assert calc.add(1, 1) == 2
@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
@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")
@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"
)
try:
raise NotFoundError("User", "abc-123")
except NotFoundError as e:
print(e.code)
print(e.resource)
print(e.message)
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).
def calculate_discount(price, discount_pct):
assert 0 <= discount_pct <= 100, f"Invalid discount: {discount_pct}"
return price * (1 - discount_pct / 100)
def process_payment(amount):
assert amount > 0
if amount <= 0:
raise ValueError("Payment amount must be positive")
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)
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
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))
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:
from multiprocessing import Pool
with Pool(4) as pool:
results = pool.map(cpu_heavy_function, data)
import numpy as np
result = np.dot(large_matrix_a, large_matrix_b)
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()
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)
return item * 2
items = range(20)
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}")
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
yield data["results"]
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
counter = Value('i', 0)
def increment(counter, n):
for _ in range(n):
with counter.get_lock():
counter.value += 1
shared_arr = Array('d', [0.0, 0.0, 0.0])
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)
p.join()
with Manager() as manager:
shared_dict = manager.dict()
shared_list = manager.list()
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:
- Reference counting - each object tracks how many references point to it. When it hits zero, the object is immediately deallocated.
- Garbage collector - handles circular references that reference counting can't catch.
- Memory allocator (pymalloc) - optimized allocator for small objects (< 512 bytes).
import sys
a = [1, 2, 3]
print(sys.getrefcount(a))
b = a
print(sys.getrefcount(a))
del b
print(sys.getrefcount(a))
class Node:
def __init__(self):
self.parent = None
self.children = []
parent = Node()
child = Node()
parent.children.append(child)
child.parent = parent
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
print(gc.get_stats())
gc.collect()
gc.disable()
gc.enable()
gc.set_debug(gc.DEBUG_LEAK)
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.
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.printf(b"Hello from C! %d\n", 42)
libfast = ctypes.CDLL("./libfast.so")
arr = (ctypes.c_int * 5)(1, 2, 3, 4, 5)
result = libfast.sum_array(arr, 5)
from cffi import FFI
ffi = FFI()
ffi.cdef("double sqrt(double x);")
lib = ffi.dlopen("libm.so.6")
print(lib.sqrt(2.0))
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.
def fib_python(int n):
"""Regular Python speed."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
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?
import cProfile
cProfile.run('my_function()', sort='cumulative')
from memory_profiler import profile
@profile
def memory_hungry():
big_list = [i ** 2 for i in range(1_000_000)]
return sum(big_list)
import timeit
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")
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."
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
regular = [RegularPoint(i, i, i) for i in range(1_000_000)]
slotted = [SlottedPoint(i, i, i) for i in range(1_000_000)]
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+):
Type parameter syntax (3.12) - PEP 695:
from typing import TypeVar, Generic
T = TypeVar('T')
class Stack(Generic[T]):
def push(self, item: T) -> None: ...
def pop(self) -> T: ...
class Stack[T]:
def push(self, item: T) -> None: ...
def pop(self) -> T: ...
def first[T](items: list[T]) -> T:
return items[0]
type Vector = list[float]
type Matrix[T] = list[list[T]]
Per-interpreter GIL (3.12):
Free-threaded CPython (3.13 - experimental):
48. What typing improvements matter most for interview code?
Modern Python typing is much cleaner than it was even two years ago.
def parse(value: str | int | None) -> dict:
...
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
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):
print(" ".join(items))
from typing import TypeVarTuple, Unpack
Ts = TypeVarTuple('Ts')
def apply_all(funcs: tuple[Unpack[Ts]], value: int):
...
from typing import override
class Parent:
def greet(self) -> str:
return "hello"
class Child(Parent):
@override
def greet(self) -> str:
return "hi"
@override
def greet_typo(self) -> str:
return "hey"
Python 3.11 was the "faster CPython" release, and 3.12-3.13 continued the trend.
try:
value = cache[key]
except KeyError:
value = compute(key)
import sys
print(sys.version)
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:
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"
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}, ..."
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.