Python Developer Interview Questions and How to Nail Your Answers (50+ Questions)
You studied. You practiced on LeetCode. You know what a generator is.
Then the interviewer asks you to explain the difference between __str__ and __repr__, and your brain goes completely blank.
That gap — between knowing the material and retrieving it under pressure — is exactly what kills candidates in technical interviews. This guide closes it.
Below you'll find 50+ Python interview questions organized by category, with complete answers, real code you can run, and the reasoning interviewers actually want to hear. Not surface-level definitions — the kind of answer that makes a senior engineer nod and say "this person knows their stuff."
Table of Contents
- 1[Python Fundamentals](#fundamentals)
- 2[Data Structures and Collections](#data-structures)
- 3[Object-Oriented Programming](#oop)
- 4[Functions, Closures, and Decorators](#functions)
- 5[Generators and Iterators](#generators)
- 6[Concurrency and Parallelism](#concurrency)
- 7[Memory Management and Performance](#memory)
- 8[Testing and Code Quality](#testing)
- 9[System Design and Architecture](#architecture)
- 10[Advanced Python and CPython Internals](#advanced)
How to Use This Guide
For each question, the answer has three layers:
- The short answer — what you say in the first 20 seconds
- The deep dive — what separates a "pass" from a "strong hire"
- The code — working examples you should type out, not just read
Read the explanation, then close the guide and try to reconstruct the code from memory. That's the retrieval practice that actually prepares you.
Python Fundamentals {#fundamentals}
1. What is the difference between a mutable and an immutable object? Why does it matter?
Short answer: A mutable object can be changed after creation (lists, dicts, sets). An immutable object cannot (ints, strings, tuples). This distinction drives some of Python's most surprising behaviors.
Deep dive:
Immutability isn't just a philosophical property — it has concrete consequences:
- Dictionary keys must be immutable (they need a stable hash)
- Default mutable arguments are a classic bug source
- String interning is possible because strings can't change
# The classic mutable default argument bug
def append_to(element, to=[]):
to.append(element)
return to
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← NOT [2]. The list persists across calls.
# The correct pattern
def append_to_fixed(element, to=None):
if to is None:
to = []
to.append(element)
return toWhy interviewers ask this: They want to see if you understand Python's object model at a deeper level than "lists are mutable." The best answer mentions the default argument gotcha — it's something every working Python developer has been bitten by.
2. Explain Python's `is` operator vs `==`. When does `is` give you a surprising result?
Short answer: == checks value equality. is checks identity — whether two variables point to the same object in memory.
Deep dive:
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True (same values)
print(a is b) # False (different objects)
# The surprising part: integer caching
x = 256
y = 256
print(x is y) # True — CPython caches small ints (-5 to 256)
x = 257
y = 257
print(x is y) # False — outside the cache range (implementation detail!)
# String interning
s1 = "hello"
s2 = "hello"
print(s1 is s2) # True — CPython interns short strings that look like identifiersThe rule of thumb: Only use is for singleton comparisons (None, True, False). Never use is to compare strings or numbers in production code — the caching behavior is a CPython implementation detail, not guaranteed by the language spec.
3. What happens when you write `a, b = b, a` in Python? Walk me through the evaluation order.
Short answer: Python evaluates the entire right-hand side before assigning, which makes the swap work correctly without a temp variable.
Deep dive:
a = 1
b = 2
a, b = b, a
# Python evaluates: (b, a) → creates the tuple (2, 1)
# Then unpacks: a = 2, b = 1
print(a, b) # 2 1The right-hand side creates a tuple (2, 1) first. Then Python unpacks it into a and b. This is why there's no need for temp = a; a = b; b = temp.
This also works for more complex swaps:
# Fibonacci in one line using tuple unpacking
a, b = 0, 1
for _ in range(10):
print(a)
a, b = b, a + b4. What is the GIL? How does it affect multithreaded Python code?
Short answer: The Global Interpreter Lock (GIL) is a mutex that allows only one thread to execute Python bytecode at a time in CPython. It prevents true parallelism for CPU-bound tasks in threads.
Deep dive:
The GIL exists because CPython's memory management (reference counting) is not thread-safe. The GIL is a pragmatic solution: simple to implement, avoids race conditions in the interpreter itself, but limits CPU parallelism.
What this means in practice:
import threading
import time
# CPU-bound: threads won't help due to GIL
def count_up(n):
count = 0
while count < n:
count += 1
# This takes roughly the same time single or multi-threaded (CPU-bound)
# because only one thread runs at a time
# I/O-bound: threads DO help, because the GIL is released during I/O
import urllib.request
def fetch_url(url):
urllib.request.urlopen(url) # GIL released during network wait
# For true CPU parallelism, use multiprocessing or concurrent.futures.ProcessPoolExecutor
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(range(n))
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**7, 10**7, 10**7, 10**7]))The nuance interviewers love: The GIL is released during I/O operations, time.sleep(), and calls to C extensions that explicitly release it (like NumPy operations). So threads are perfectly fine for I/O-bound work — only CPU-bound parallelism requires multiprocessing.
5. What is the difference between `deepcopy` and `copy`?
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
# Modify a nested list
original[0].append(99)
print(shallow[0]) # [1, 2, 99] ← shallow copy shares nested objects
print(deep[0]) # [1, 2] ← deep copy is fully independentThe mental model: copy creates a new container but reuses the same inner objects. deepcopy recursively creates new objects all the way down. Use deepcopy when you have nested mutable structures and need full independence. Use copy when the inner objects are immutable (e.g., a list of strings).
Data Structures and Collections {#data-structures}
6. When would you use a `list` vs a `tuple` vs a `set`?
| Structure | Use when |
|---|---|
| list | Order matters, duplicates allowed, you need to mutate |
| tuple | Order matters, data is fixed (coordinates, DB row, dict key) |
| set | You need fast membership testing, or uniqueness matters |
# Sets for O(1) membership testing — huge performance difference at scale
items_list = list(range(1_000_000))
items_set = set(range(1_000_000))
# This is O(n)
999_999 in items_list # slow
# This is O(1) — hash lookup
999_999 in items_set # fast
# Set operations
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a & b) # {3, 4} — intersection
print(a | b) # {1, 2, 3, 4, 5, 6} — union
print(a - b) # {1, 2} — difference7. How does Python's `dict` work internally? What changed in Python 3.7+?
Short answer: Dicts use a hash table under the hood, providing O(1) average-case lookups. Since Python 3.7, dicts are guaranteed to maintain insertion order (it was an implementation detail in 3.6).
Deep dive:
# Insertion order is preserved in Python 3.7+
user = {}
user["name"] = "Ana"
user["city"] = "Buenos Aires"
user["role"] = "engineer"
print(list(user.keys())) # ['name', 'city', 'role'] — insertion order guaranteed
# dict.get() with a default avoids KeyError
config = {"debug": True}
timeout = config.get("timeout", 30) # returns 30, not KeyError
# dict comprehension
squares = {n: n**2 for n in range(1, 6)}
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Merging dicts (Python 3.9+)
defaults = {"color": "blue", "size": 10}
overrides = {"color": "red"}
merged = defaults | overrides
# {'color': 'red', 'size': 10}Hash collisions: When two keys hash to the same slot, Python uses open addressing (probing). This is why keys must be hashable — a mutable object's hash could change if the object changes, breaking the lookup guarantee.
8. What is a `defaultdict` and when is it better than a regular dict?
from collections import defaultdict
# Regular dict — KeyError if key doesn't exist
word_count = {}
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
for word in words:
if word not in word_count:
word_count[word] = 0
word_count[word] += 1
# defaultdict — cleaner
word_count = defaultdict(int) # int() returns 0 by default
for word in words:
word_count[word] += 1
print(dict(word_count)) # {'apple': 3, 'banana': 2, 'cherry': 1}
# defaultdict with list — great for grouping
from collections import defaultdict
employees_by_dept = defaultdict(list)
data = [("eng", "Ana"), ("eng", "Carlos"), ("sales", "Maria")]
for dept, name in data:
employees_by_dept[dept].append(name)
# {'eng': ['Ana', 'Carlos'], 'sales': ['Maria']}Also worth mentioning: Counter (from collections) is a specialized dict for counting that comes with convenience methods like most_common().
9. Explain list comprehensions vs generator expressions. When would you prefer one over the other?
# List comprehension — creates the entire list in memory immediately
squares_list = [x**2 for x in range(1_000_000)]
# Uses ~8MB of memory right now
# Generator expression — lazy evaluation, yields one item at a time
squares_gen = (x**2 for x in range(1_000_000))
# Uses ~200 bytes — just the generator object
# If you only need to iterate once, use a generator
total = sum(x**2 for x in range(1_000_000)) # No list ever created
# If you need to iterate multiple times, use a list
squares = [x**2 for x in range(100)]
for s in squares:
print(s)
for s in squares:
print(s * 2) # Can iterate again — generators can'tThe rule: Use a generator expression when you're feeding into a function like sum(), max(), min(), or any loop where you process each item once. Use a list comprehension when you need random access, multiple iterations, or need to know the length.
10. What are `namedtuple` and `dataclass`? When do you use each?
from collections import namedtuple
# namedtuple — lightweight, immutable, memory-efficient
Point = namedtuple('Point', ['x', 'y'])
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p[0]) # 3 — still works as a tuple
# p.x = 10 # AttributeError — immutable
# dataclass (Python 3.7+) — more powerful, mutable by default
from dataclasses import dataclass, field
from typing import List
@dataclass
class Employee:
name: str
department: str
salary: float
skills: List[str] = field(default_factory=list)
def annual_salary(self) -> float:
return self.salary * 12
emp = Employee("Ana", "Engineering", 5000)
emp.skills.append("Python") # Mutable
print(emp.annual_salary()) # 60000.0
# @dataclass(frozen=True) makes it immutable like namedtuple
# @dataclass(order=True) adds comparison methods automaticallyThe choice:
namedtuple: simple record, needs to be a tuple, needs to be hashable, performance-criticaldataclass: anything more complex — methods, mutability, default values, inheritance
Object-Oriented Programming {#oop}
11. What is the difference between `__str__` and `__repr__`?
Short answer: __repr__ is for developers (unambiguous, ideally valid Python to reconstruct the object). __str__ is for end users (readable). When in doubt, implement __repr__ — it's used as the fallback when __str__ isn't defined.
from datetime import datetime
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __repr__(self):
# Should be unambiguous — ideally enough to recreate the object
return f"Temperature({self.celsius!r})"
def __str__(self):
# Human-readable
return f"{self.celsius}°C"
t = Temperature(25.5)
print(repr(t)) # Temperature(25.5)
print(str(t)) # 25.5°C
print(t) # 25.5°C ← print() calls __str__
# In a list, __repr__ is used
temps = [Temperature(20), Temperature(30)]
print(temps) # [Temperature(20), Temperature(30)]12. Explain `classmethod` vs `staticmethod` vs a regular method.
class DateParser:
date_format = "%Y-%m-%d"
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# Regular method — receives instance as first arg (self)
def to_string(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
# classmethod — receives the CLASS as first arg (cls), not the instance
# Used as alternative constructors
@classmethod
def from_string(cls, date_string):
year, month, day = map(int, date_string.split("-"))
return cls(year, month, day) # Works correctly even in subclasses
# staticmethod — no implicit first arg; a regular function in the class namespace
# Use when the method is logically related to the class but doesn't need access to it
@staticmethod
def is_valid_format(date_string):
parts = date_string.split("-")
return len(parts) == 3 and all(p.isdigit() for p in parts)
# Usage
d = DateParser.from_string("2024-03-15") # classmethod as factory
print(d.to_string()) # 2024-03-15
print(DateParser.is_valid_format("2024-03-15")) # True13. What is MRO (Method Resolution Order)? How does Python resolve multiple inheritance?
Short answer: MRO is the order Python searches classes to find a method. Python uses the C3 linearization algorithm, and you can inspect it with ClassName.__mro__.
class A:
def who(self):
return "A"
class B(A):
def who(self):
return "B"
class C(A):
def who(self):
return "C"
class D(B, C):
pass
d = D()
print(d.who()) # "B" — found in B before C
print(D.__mro__) # (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
# super() follows the MRO — critical for cooperative multiple inheritance
class Base:
def greet(self):
return "Hello from Base"
class Left(Base):
def greet(self):
return "Left + " + super().greet()
class Right(Base):
def greet(self):
return "Right + " + super().greet()
class Child(Left, Right):
def greet(self):
return "Child + " + super().greet()
# Child → Left → Right → Base (each super() calls the NEXT in MRO, not the parent)
print(Child().greet())
# "Child + Left + Right + Hello from Base"14. What are Python properties (`@property`)? When would you use them over a plain attribute?
class Circle:
def __init__(self, radius):
self._radius = radius # Convention: _ prefix means "private"
@property
def radius(self):
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 # Computed, no storage needed
c = Circle(5)
print(c.radius) # 5 — looks like attribute access, calls getter
c.radius = 10 # calls setter — validates input
print(c.area) # 314.159...
# c.area = 50 # AttributeError — no setter defined (read-only computed property)When to use: Start with a plain attribute. Add @property when you need validation on set, or when a value should be computed from other attributes. This is better than writing get_radius()/set_radius() Java-style.
15. What is `__slots__`? When and why would you use it?
class RegularPoint:
def __init__(self, x, y):
self.x = x
self.y = y
# Has __dict__ by default — can add arbitrary attributes
class SlottedPoint:
__slots__ = ['x', 'y'] # Declares ALL allowed instance attributes upfront
def __init__(self, x, y):
self.x = x
self.y = y
# No __dict__ — fixed attribute set only
# Memory comparison
import sys
p1 = RegularPoint(1, 2)
p2 = SlottedPoint(1, 2)
print(sys.getsizeof(p1.__dict__)) # ~104 bytes (the dict overhead)
# SlottedPoint has no __dict__ — saves 40-50% memory for large collections
# When to use: classes where you create millions of instances
# (e.g., graph nodes, matrix cells, event records)Functions, Closures, and Decorators {#functions}
16. What is a closure? Give a real-world use case.
Short answer: A closure is a function that "closes over" variables from its enclosing scope — those variables persist even after the outer function returns.
def make_multiplier(factor):
# 'factor' is a free variable — captured by the closure
def multiply(x):
return x * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# Each closure has its own captured state
print(double.__closure__[0].cell_contents) # 2
# Real-world use case: configurable functions, avoiding global state
def make_validator(min_val, max_val):
def validate(value):
if not (min_val <= value <= max_val):
raise ValueError(f"Value {value} must be between {min_val} and {max_val}")
return value
return validate
validate_age = make_validator(0, 150)
validate_score = make_validator(0, 100)
validate_age(25) # OK
validate_score(95) # OK
# validate_score(105) # ValueError17. Write a decorator that measures execution time. Explain how decorators work.
import time
import functools
def timer(func):
@functools.wraps(func) # Preserves the wrapped function's metadata
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):
"""Sums numbers up to n."""
return sum(range(n))
slow_function(10_000_000) # slow_function took 0.3812s
# What @timer actually does:
# slow_function = timer(slow_function)
# It's syntactic sugar for that assignment
# Why functools.wraps matters:
print(slow_function.__name__) # "slow_function" — not "wrapper"
print(slow_function.__doc__) # "Sums numbers up to n."Decorator with arguments — a common interview follow-up:
def retry(max_attempts=3, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
return wrapper
return decorator
@retry(max_attempts=3, exceptions=(ConnectionError,))
def fetch_data(url):
# Simulates a flaky network call
import random
if random.random() < 0.7:
raise ConnectionError("Network error")
return "data"18. What is `*args` and `**kwargs`? When do you use them?
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# *args captures positional args as a tuple
# **kwargs captures keyword args as a dict
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
return func(*args, **kwargs)
return wrapper
# Positional-only vs keyword-only arguments (Python 3.8+)
def strict_function(pos_only, /, normal, *, kw_only):
# pos_only: must be passed positionally (before /)
# normal: either way
# kw_only: must be passed by name (after *)
return pos_only + normal + kw_only
strict_function(1, 2, kw_only=3) # OK
strict_function(1, normal=2, kw_only=3) # OK
# strict_function(pos_only=1, ...) # TypeError — pos_only can't be a keyword arg19. What is a lambda? When should you use one vs a regular `def`?
# Lambda: anonymous function, single expression
square = lambda x: x ** 2
# Use lambdas for short, throwaway functions in higher-order functions
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
sorted_numbers = sorted(numbers, key=lambda x: -x) # Sort descending
print(sorted_numbers) # [9, 6, 5, 4, 3, 2, 1, 1]
# Sorting complex objects
employees = [
{"name": "Ana", "salary": 5000},
{"name": "Bob", "salary": 7000},
{"name": "Carlos", "salary": 4500},
]
by_salary = sorted(employees, key=lambda e: e["salary"], reverse=True)
# When NOT to use lambda: anything complex, anything you name and reuse
# This is bad:
process = lambda x: x.strip().lower().replace(" ", "_")
# This is better (readable, testable, documented):
def to_snake_case(text):
"""Convert a string to snake_case."""
return text.strip().lower().replace(" ", "_")Generators and Iterators {#generators}
20. How does `yield` work? What is the difference between a generator function and a regular function?
Short answer: A generator function uses yield to pause execution and return a value, then resume from that point on the next next() call. It maintains its local state between calls.
def count_up_to(max_val):
"""A generator that counts from 0 to max_val."""
n = 0
while n <= max_val:
yield n # Pause here, return n to caller
n += 1 # Resume here on next next() call
gen = count_up_to(3)
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
# next(gen) # StopIteration — generator exhausted
# Generators are memory-efficient for large sequences
def fibonacci():
"""Infinite Fibonacci sequence — never runs out of memory."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
# Real use case: processing large files line by line
def read_large_file(filepath):
with open(filepath) as f:
for line in f:
yield line.strip()
# This never loads the entire file into memory
for line in read_large_file("huge_log.txt"):
if "ERROR" in line:
print(line)21. What is `yield from`? When is it useful?
# Without yield from — tedious delegation
def chain_v1(*iterables):
for iterable in iterables:
for item in iterable:
yield item
# With yield from — cleaner and more powerful
def chain_v2(*iterables):
for iterable in iterables:
yield from iterable # Delegates to the sub-iterator
print(list(chain_v2([1, 2], [3, 4], [5]))) # [1, 2, 3, 4, 5]
# yield from also passes .send() and .throw() through — essential for coroutines
# Real use case: flattening nested structures
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # Recursive delegation
else:
yield item
print(list(flatten([1, [2, [3, 4]], [5, 6]]))) # [1, 2, 3, 4, 5, 6]22. What is the iterator protocol? Implement a custom iterator.
# The iterator protocol: __iter__ returns self, __next__ returns the next value
class Range:
"""A simplified reimplementation of range()."""
def __init__(self, start, stop, step=1):
self.current = start
self.stop = stop
self.step = step
def __iter__(self):
return self # The iterator IS this object
def __next__(self):
if self.current >= self.stop:
raise StopIteration # Signal that iteration is complete
value = self.current
self.current += self.step
return value
for n in Range(0, 5):
print(n) # 0, 1, 2, 3, 4
# An iterable (has __iter__) is NOT the same as an iterator (also has __next__)
# A list is iterable but not an iterator — calling iter(list) returns a new iterator
my_list = [1, 2, 3]
it = iter(my_list)
print(next(it)) # 1
print(next(it)) # 2Concurrency and Parallelism {#concurrency}
23. What is `asyncio`? When should you use `async`/`await` instead of threads?
Short answer: asyncio is Python's framework for cooperative multitasking using coroutines. It's ideal for I/O-bound workloads where you're waiting on network requests, databases, or file I/O.
import asyncio
import aiohttp # pip install 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:
# Run all fetches concurrently — not sequentially
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
# With asyncio: ~1 second total (concurrent)
# With sequential requests: ~3 seconds total
asyncio.run(fetch_all(urls))The decision matrix:
| Workload | Best choice |
|---|---|
| I/O-bound, many concurrent requests | asyncio |
| I/O-bound, existing synchronous code | threading |
| CPU-bound (data processing) | multiprocessing |
| CPU-bound (NumPy/pandas) | These release the GIL — threads work fine |
24. What is the difference between `threading.Lock`, `threading.RLock`, and `asyncio.Lock`?
import threading
# Lock — basic mutual exclusion
counter = 0
lock = threading.Lock()
def increment():
global counter
with lock: # Context manager — acquires and releases automatically
counter += 1
# RLock (reentrant lock) — can be acquired multiple times by the same thread
rlock = threading.RLock()
def outer():
with rlock:
inner() # Would deadlock with regular Lock!
def inner():
with rlock: # Same thread can acquire again
print("Inside inner")
# asyncio.Lock — for coroutines (not threads)
import asyncio
async_lock = asyncio.Lock()
async def safe_operation():
async with async_lock:
await asyncio.sleep(0) # Simulate async work
# Only one coroutine at a time here25. What are `concurrent.futures.ThreadPoolExecutor` and `ProcessPoolExecutor`? Write a real example.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests # pip install requests
# ThreadPoolExecutor — I/O-bound tasks (network, disk)
def fetch_status(url):
response = requests.get(url, timeout=5)
return url, response.status_code
urls = ["https://google.com", "https://github.com", "https://python.org"]
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_status, url): url for url in urls}
for future in futures:
url, status = future.result()
print(f"{url}: {status}")
# ProcessPoolExecutor — CPU-bound tasks
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
numbers = range(10_000, 10_100)
with ProcessPoolExecutor() as executor:
results = list(executor.map(is_prime, numbers))
primes = [n for n, is_p in zip(numbers, results) if is_p]
print(primes)Memory Management and Performance {#memory}
26. How does Python's garbage collector work? What is reference counting?
Short answer: Python primarily uses reference counting — every object tracks how many references point to it. When the count hits zero, the object is freed immediately. A cyclic garbage collector handles reference cycles.
import sys
import gc
# Reference counting in action
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (one from 'a', one for the getrefcount arg)
b = a # Second reference
print(sys.getrefcount(a)) # 3
del b # Remove one reference
print(sys.getrefcount(a)) # 2
# Reference cycles — NOT cleaned by reference counting alone
class Node:
def __init__(self):
self.sibling = None
n1 = Node()
n2 = Node()
n1.sibling = n2
n2.sibling = n1 # Cycle: n1 ↔ n2
del n1, n2 # Reference counts go to 1, not 0 — memory not freed immediately
# Python's cyclic GC will clean this up in a future collection
gc.collect() # Force a collection cycle
# Context managers guarantee cleanup regardless of ref cycles
with open("file.txt", "w") as f:
f.write("data")
# File is closed here — even if an exception occurred27. What is memory profiling? Walk me through how you'd find a memory leak.
# Using tracemalloc (built-in since Python 3.4)
import tracemalloc
tracemalloc.start()
# --- Code to profile ---
data = []
for i in range(100_000):
data.append({"id": i, "value": i * 2})
# --- End of profiled code ---
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:5]:
print(stat)
# Output shows which line allocated the most memory
# Using memory_profiler for line-by-line profiling
# pip install memory_profiler
from memory_profiler import profile
@profile
def memory_hungry():
big_list = [0] * 1_000_000 # ~8MB
del big_list
return [1] * 500_000 # ~4MB
# Common memory leak patterns:
# 1. Growing cache without eviction (use functools.lru_cache or cachetools.TTLCache)
# 2. Unclosed file handles / database connections
# 3. Global lists/dicts that accumulate without cleanup
# 4. Circular references with __del__ methods28. What is `functools.lru_cache`? When should you use it?
from functools import lru_cache
import time
# Without cache — exponential time for naive fibonacci
def fib_slow(n):
if n <= 1:
return n
return fib_slow(n - 1) + fib_slow(n - 2)
# With cache — O(n) time, O(n) space
@lru_cache(maxsize=None) # maxsize=None means unlimited cache (functools.cache in 3.9+)
def fib_fast(n):
if n <= 1:
return n
return fib_fast(n - 1) + fib_fast(n - 2)
start = time.time()
fib_slow(35)
print(f"Without cache: {time.time() - start:.3f}s") # ~3s
start = time.time()
fib_fast(35)
print(f"With cache: {time.time() - start:.3f}s") # ~0.000s
# Check cache stats
print(fib_fast.cache_info()) # CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)
# Important: the function must be deterministic and its args must be hashable
# Don't cache functions with side effectsTesting and Code Quality {#testing}
29. How do you write unit tests in Python? What is `unittest.mock`?
# pytest (the standard choice for most projects)
# pip install pytest
# calculator.py
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# test_calculator.py
import pytest
from calculator import divide
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_float():
assert divide(7, 3) == pytest.approx(2.333, rel=1e-3) # floating point comparison
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
@pytest.mark.parametrize("a, b, expected", [
(10, 2, 5.0),
(9, 3, 3.0),
(-6, 2, -3.0),
])
def test_divide_parametrize(a, b, expected):
assert divide(a, b) == expected# Mocking with unittest.mock
from unittest.mock import Mock, patch, MagicMock
# Mocking an external API call
import requests
from unittest.mock import patch
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
def test_get_user_data():
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Ana"}
mock_response.raise_for_status.return_value = None
with patch("requests.get", return_value=mock_response) as mock_get:
result = get_user_data(1)
mock_get.assert_called_once_with("https://api.example.com/users/1")
assert result == {"id": 1, "name": "Ana"}30. What are fixtures in pytest? Write an example using a database fixture.
import pytest
import sqlite3
@pytest.fixture
def db_connection():
"""Creates an in-memory database, yields it to the test, then tears it down."""
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.commit()
yield conn # Test runs here
conn.close() # Teardown happens after yield
@pytest.fixture
def sample_user(db_connection):
"""Depends on db_connection — fixtures can depend on other fixtures."""
cursor = db_connection.cursor()
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("Ana", "ana@test.com"))
db_connection.commit()
return cursor.lastrowid
def test_user_exists(db_connection, sample_user):
cursor = db_connection.cursor()
cursor.execute("SELECT name FROM users WHERE id = ?", (sample_user,))
row = cursor.fetchone()
assert row is not None
assert row[0] == "Ana"
# Fixture scopes: function (default), class, module, session
# @pytest.fixture(scope="session") — run once for the entire test suiteSystem Design and Architecture {#architecture}
31. What are context managers? Write one using both `__enter__`/`__exit__` and `@contextmanager`.
# Method 1: Class-based
class DatabaseConnection:
def __init__(self, host, port):
self.host = host
self.port = port
self.connection = None
def __enter__(self):
print(f"Connecting to {self.host}:{self.port}")
self.connection = {"host": self.host, "port": self.port} # Simulated
return self.connection # This is what 'as' receives
def __exit__(self, exc_type, exc_val, exc_tb):
print("Closing connection")
self.connection = None
# Return True to suppress exceptions, False (or None) to propagate
if exc_type is not None:
print(f"Exception during connection: {exc_val}")
return False # Don't suppress exceptions
with DatabaseConnection("localhost", 5432) as conn:
print(f"Using: {conn}")
# Method 2: Generator-based (simpler for most use cases)
from contextlib import contextmanager
@contextmanager
def timer(label):
import time
start = time.perf_counter()
try:
yield # Code in the 'with' block runs here
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.4f}s")
with timer("data processing"):
result = sum(range(10_000_000))32. What are Python's data classes? Compare with Pydantic.
from dataclasses import dataclass
from typing import Optional
@dataclass
class UserConfig:
name: str
age: int
email: Optional[str] = None
def __post_init__(self):
if self.age < 0:
raise ValueError("Age cannot be negative")
# dataclasses validate at the type level only if you add __post_init__
# They don't automatically coerce types
# Pydantic — runtime type validation and coercion (popular in APIs)
from pydantic import BaseModel, validator, Field
class UserModel(BaseModel):
name: str
age: int = Field(..., ge=0, le=150) # greater-than-or-equal 0, less-than 150
email: Optional[str] = None
@validator("name")
def name_not_empty(cls, v):
if not v.strip():
raise ValueError("Name cannot be empty")
return v.title() # Coerce to title case
user = UserModel(name="ana garcia", age="25") # Note: "25" string → coerced to int
print(user.name) # "Ana Garcia"
print(user.age) # 25 (int, not "25")
# user = UserModel(name="", age=200) # ValidationError — both fields fail33. Explain Python's `abc` module. When and why do you use Abstract Base Classes?
from abc import ABC, abstractmethod
from typing import List
class DataStore(ABC):
"""Abstract interface for any data storage backend."""
@abstractmethod
def get(self, key: str) -> dict:
"""Retrieve a record by key."""
...
@abstractmethod
def put(self, key: str, value: dict) -> None:
"""Store a record."""
...
@abstractmethod
def delete(self, key: str) -> bool:
"""Delete a record. Returns True if it existed."""
...
def get_or_default(self, key: str, default: dict) -> dict:
"""Concrete method — shared behavior across all implementations."""
try:
return self.get(key)
except KeyError:
return default
# Concrete implementation
class InMemoryStore(DataStore):
def __init__(self):
self._data = {}
def get(self, key):
if key not in self._data:
raise KeyError(key)
return self._data[key]
def put(self, key, value):
self._data[key] = value
def delete(self, key):
if key in self._data:
del self._data[key]
return True
return False
# DataStore() # TypeError: Can't instantiate abstract class
store = InMemoryStore() # OK — all abstract methods implemented
store.put("user:1", {"name": "Ana"})
print(store.get_or_default("user:2", {"name": "Unknown"}))Advanced Python and CPython Internals {#advanced}
34. What are metaclasses? Give a real use case beyond "it's classes all the way down."
# A metaclass controls how a class is created
# type is the default metaclass of all classes
# Use case: auto-register all subclasses (plugin system, strategy pattern)
class PluginRegistry(type):
_registry = {}
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
# Don't register the base class itself
if bases:
mcs._registry[name] = cls
return cls
@classmethod
def get_plugin(mcs, name):
return mcs._registry.get(name)
class BasePlugin(metaclass=PluginRegistry):
def execute(self):
raise NotImplementedError
class JSONPlugin(BasePlugin):
def execute(self):
return "Processing JSON"
class XMLPlugin(BasePlugin):
def execute(self):
return "Processing XML"
# Discover all registered plugins dynamically
print(PluginRegistry._registry)
# {'JSONPlugin': <class '__main__.JSONPlugin'>, 'XMLPlugin': <class '__main__.XMLPlugin'>}
plugin_class = PluginRegistry.get_plugin("JSONPlugin")
print(plugin_class().execute()) # "Processing JSON"35. What is `__new__` vs `__init__`? When do you override `__new__`?
# __init__ initializes an already-created instance
# __new__ creates and returns the instance
# Singleton pattern using __new__
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# Be careful: __init__ is called every time Singleton() is called
# even though __new__ returns the same object
pass
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # True — same object
# Immutable subclassing: overriding __new__ to transform input
class UpperStr(str):
def __new__(cls, value):
return super().__new__(cls, value.upper())
s = UpperStr("hello")
print(s) # "HELLO"
print(type(s)) # <class '__main__.UpperStr'>36. What is Python's descriptor protocol? How do `@property`, `classmethod`, and `staticmethod` use it?
# A descriptor is any object that defines __get__, __set__, or __delete__
# This is the mechanism behind @property, classmethod, staticmethod, and more
class Validator:
"""A descriptor that validates a numeric range."""
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
self.attr_name = None
def __set_name__(self, owner, name):
self.attr_name = name # Python 3.6+ — called when class is defined
def __get__(self, obj, objtype=None):
if obj is None:
return self # Accessed on class, not instance
return obj.__dict__.get(self.attr_name)
def __set__(self, obj, value):
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.attr_name} must be between {self.min_val} and {self.max_val}"
)
obj.__dict__[self.attr_name] = value
class Person:
age = Validator(0, 150)
score = Validator(0, 100)
def __init__(self, age, score):
self.age = age # Calls Validator.__set__
self.score = score
p = Person(25, 95)
# p.age = 200 # ValueError: age must be between 0 and 15037. What is the `__slots__` vs `__dict__` tradeoff in detail?
import sys
class WithDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class WithSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
d = WithDict(1, 2, 3)
s = WithSlots(1, 2, 3)
print(sys.getsizeof(d) + sys.getsizeof(d.__dict__)) # ~240 bytes
print(sys.getsizeof(s)) # ~72 bytes (~66% smaller)
# Tradeoffs:
# __slots__ gains: memory, slightly faster attribute access
# __slots__ loses:
# - No dynamic attribute assignment
# - No __dict__ (can't serialize easily with some libraries)
# - Inheritance with __slots__ requires careful design
# - Multiple inheritance with __slots__ is complex
# Rule: use __slots__ only when profiling shows memory is a bottleneck
# and you're creating many instances of the same class38. Explain `__enter__` and `__exit__` in the context of exception handling.
class ManagedFile:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_value, traceback):
self.file.close()
# exc_type: the exception class (None if no exception)
# exc_value: the exception instance
# traceback: the traceback object
if exc_type is FileNotFoundError:
print(f"File not found — handled gracefully")
return True # Suppress this exception
# Return None or False for all other exceptions — they propagate normally
return False
# Exception is suppressed:
with ManagedFile("nonexistent.txt", "r") as f:
data = f.read()
# Continues here because FileNotFoundError was suppressed
# Exception propagates:
# with ManagedFile("file.txt", "r") as f:
# raise ValueError("something went wrong")
# ValueError is NOT suppressed — propagates after file is closed39. What is `__call__`? Write a class that behaves like a function.
class Memoize:
"""A callable class that caches function results."""
def __init__(self, func):
self.func = func
self.cache = {}
# Preserve function metadata
import functools
functools.update_wrapper(self, func)
def __call__(self, *args):
if args not in self.cache:
self.cache[args] = self.func(*args)
return self.cache[args]
@Memoize # Equivalent to: fib = Memoize(fib)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(50)) # Fast — cached
# Another use case: configurable predicates
class Between:
def __init__(self, low, high):
self.low = low
self.high = high
def __call__(self, value):
return self.low <= value <= self.high
is_valid_age = Between(0, 150)
is_valid_score = Between(0, 100)
print(is_valid_age(25)) # True
print(is_valid_score(105)) # False
# Can be passed anywhere a function is expected
ages = [25, -1, 200, 42]
valid_ages = list(filter(is_valid_age, ages))
print(valid_ages) # [25, 42]40. What is the difference between `__getattr__` and `__getattribute__`?
class SmartDict:
def __init__(self, data):
# Use object.__setattr__ to avoid triggering our own __setattr__
object.__setattr__(self, "_data", data)
def __getattr__(self, name):
# Called ONLY when normal attribute lookup fails
# (i.e., the attribute doesn't exist on the object or its class)
try:
return self._data[name]
except KeyError:
raise AttributeError(f"No attribute or key '{name}'")
def __getattribute__(self, name):
# Called for EVERY attribute access — powerful but dangerous
# If you override this, you must be very careful to avoid infinite recursion
return object.__getattribute__(self, name)
d = SmartDict({"name": "Ana", "city": "Buenos Aires"})
print(d.name) # "Ana" — falls through to __getattr__
print(d.city) # "Buenos Aires"
# print(d.age) # AttributeError
# Common use case: proxies, ORMs, dynamic attribute generation
# __getattr__: safe override for "attribute not found" fallback
# __getattribute__: intercepts ALL attribute access — use sparingly41. What is `typing` and type hints? Walk me through `Optional`, `Union`, `TypeVar`, and `Protocol`.
from typing import Optional, Union, TypeVar, Protocol, List, Dict, Callable
# Optional[X] is shorthand for Union[X, None]
def find_user(user_id: int) -> Optional[dict]:
users = {1: {"name": "Ana"}}
return users.get(user_id) # Returns dict or None
# Union — accepts multiple types
def process(data: Union[str, bytes]) -> str:
if isinstance(data, bytes):
return data.decode()
return data
# Python 3.10+ syntax: str | bytes | None
def process_v2(data: str | bytes | None) -> str:
...
# TypeVar — for generic functions
T = TypeVar("T")
def first(items: List[T]) -> Optional[T]:
return items[0] if items else None
result: Optional[int] = first([1, 2, 3]) # Type checker knows this is int
# Protocol — structural subtyping ("duck typing" with type checking)
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render(shape: Drawable) -> None:
shape.draw()
# Both Circle and Square satisfy Drawable without inheriting from it
render(Circle())
render(Square())42. What are the `__dunder__` (magic) methods you consider most important to know?
| Method | Triggered by | Common use |
|---|---|---|
| __init__ | obj = Class(...) | Initialization |
| __repr__ | repr(obj), debugging | Unambiguous representation |
| __str__ | str(obj), print(obj) | Human-readable string |
| __len__ | len(obj) | Custom containers |
| __getitem__ | obj[key] | Indexing and slicing |
| __iter__ | for x in obj | Iteration protocol |
| __contains__ | x in obj | Membership testing |
| __eq__ | obj == other | Value comparison |
| __hash__ | hash(obj), dict keys | Hashability |
| __enter__/__exit__ | with obj: | Context managers |
| __call__ | obj(...) | Callable instances |
| __add__, __mul__, etc. | +, *, etc. | Operator overloading |
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 __len__(self):
import math
return int(math.sqrt(self.x**2 + self.y**2))
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y)) # Must define __hash__ when defining __eq__
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 * 3) # Vector(3, 6)
print(len(v2)) # 543. What is `enum`? Why use it instead of constants?
from enum import Enum, auto, IntEnum
class Status(Enum):
PENDING = "pending"
ACTIVE = "active"
CLOSED = "closed"
# Better than string constants: typos become errors, not silent bugs
order_status = Status.ACTIVE
if order_status == Status.ACTIVE:
print("Order is active")
# auto() assigns values automatically
class Direction(Enum):
NORTH = auto() # 1
SOUTH = auto() # 2
EAST = auto() # 3
WEST = auto() # 4
# IntEnum — comparison with ints works (use carefully)
class HttpStatus(IntEnum):
OK = 200
NOT_FOUND = 404
SERVER_ERROR = 500
print(HttpStatus.OK == 200) # True
print(HttpStatus.OK > 199) # True
# Iteration and membership
all_statuses = list(Status)
print(Status.PENDING in Status) # True44. Walk me through how `functools.partial` works and when to use it.
from functools import partial
def power(base, exponent):
return base ** exponent
# Create a new function with some arguments pre-filled
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27
# Real-world use: adapting function signatures for higher-order functions
import os
# os.path.join requires two args, but map needs a single-arg function
base_dir = "/home/user/projects"
filenames = ["readme.txt", "main.py", "config.json"]
full_paths = list(map(partial(os.path.join, base_dir), filenames))
# ['/home/user/projects/readme.txt', '/home/user/projects/main.py', ...]
# Also useful with sorted() for complex keys
from functools import partial
def compare_by_attr(obj, attr):
return getattr(obj, attr)
data = [{"name": "Carlos", "age": 30}, {"name": "Ana", "age": 25}]
sorted_data = sorted(data, key=partial(dict.get, key="age"))45. What is the `contextlib` module? What are `suppress`, `ExitStack`, and `redirect_stdout`?
from contextlib import suppress, ExitStack, redirect_stdout
import io
# suppress — silence specific exceptions
with suppress(FileNotFoundError):
os.remove("nonexistent_file.txt")
# Continues here without error — equivalent to try/except with pass
# redirect_stdout — capture print output for testing
buffer = io.StringIO()
with redirect_stdout(buffer):
print("Hello, world")
print("Second line")
output = buffer.getvalue()
print(repr(output)) # "Hello, world\nSecond line\n"
# ExitStack — dynamically compose context managers
filenames = ["file1.txt", "file2.txt", "file3.txt"]
with ExitStack() as stack:
files = [stack.enter_context(open(f, "w")) for f in filenames]
# All files are open here
for i, f in enumerate(files):
f.write(f"File {i + 1} content")
# All files closed here, even if an exception occurred46. What is `itertools`? Name five functions and their use cases.
import itertools
# 1. chain — flatten iterables
print(list(itertools.chain([1, 2], [3, 4], [5])))
# [1, 2, 3, 4, 5]
# 2. groupby — group consecutive elements with the same key
data = [("a", 1), ("a", 2), ("b", 3), ("b", 4), ("c", 5)]
for key, group in itertools.groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2)]
# b [('b', 3), ('b', 4)]
# Note: data must be sorted by the grouping key first
# 3. product — Cartesian product
suits = ["♠", "♥", "♦", "♣"]
ranks = ["A", "2", "3"]
deck = list(itertools.product(ranks, suits))
print(deck[:4]) # [('A', '♠'), ('A', '♥'), ('A', '♦'), ('A', '♣')]
# 4. islice — slice an iterator (works on infinite iterators)
from itertools import islice
def naturals():
n = 1
while True:
yield n
n += 1
first_10 = list(islice(naturals(), 10))
print(first_10) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 5. accumulate — running totals
import operator
monthly_sales = [100, 200, 150, 300, 250]
running_total = list(itertools.accumulate(monthly_sales))
print(running_total) # [100, 300, 450, 750, 1000]
# With a custom function (Python 3.8+: initial parameter)
running_max = list(itertools.accumulate(monthly_sales, func=max))
print(running_max) # [100, 200, 200, 300, 300]47. How does Python handle exceptions? Explain the `else` and `finally` clauses.
def read_config(filepath):
try:
f = open(filepath)
except FileNotFoundError:
print(f"Config file not found: {filepath}")
return {}
except PermissionError as e:
print(f"Permission denied: {e}")
raise # Re-raise the original exception
else:
# Runs ONLY if the try block completed with NO exception
# This is the "happy path"
content = f.read()
f.close()
return content
finally:
# Runs ALWAYS — whether exception occurred or not
# Even if there's a return in the try block
print("read_config() complete")
# Exception chaining
try:
int("not a number")
except ValueError as e:
raise RuntimeError("Config parsing failed") from e
# Traceback shows both exceptions with "The above exception was the direct cause of..."
# Custom exceptions
class AppError(Exception):
"""Base exception for this application."""
pass
class ConfigError(AppError):
def __init__(self, key, message):
self.key = key
super().__init__(f"Config error for '{key}': {message}")
raise ConfigError("database_url", "must not be empty")48. What is `pathlib`? Why is it preferred over `os.path`?
from pathlib import Path
# pathlib uses an object-oriented, readable API
project_root = Path("/home/user/myproject")
# Path joining with / operator (no os.path.join)
config_file = project_root / "config" / "settings.yaml"
print(config_file) # /home/user/myproject/config/settings.yaml
# Common operations
print(config_file.exists()) # True/False
print(config_file.suffix) # '.yaml'
print(config_file.stem) # 'settings'
print(config_file.parent) # /home/user/myproject/config
print(config_file.name) # 'settings.yaml'
# Reading and writing
config_file.write_text("key: value")
content = config_file.read_text()
# Glob patterns
python_files = list(project_root.glob("**/*.py")) # Recursive
test_files = list(project_root.glob("tests/test_*.py"))
# Creating directories
(project_root / "output" / "reports").mkdir(parents=True, exist_ok=True)
# vs. os.path equivalent (less readable):
import os
config_file_old = os.path.join("/home/user/myproject", "config", "settings.yaml")49. What is a Python virtual environment? What problem does it solve?
Short answer: A virtual environment is an isolated Python installation with its own packages. It solves the "project A needs Django 3.2, project B needs Django 4.2" problem.
# Create a virtual environment
python -m venv .venv
# Activate it
source .venv/bin/activate # macOS/Linux
.venv\Scripts\activate # Windows
# Packages installed here don't affect the global Python
pip install django==4.2
pip freeze > requirements.txt # Pin all dependencies
# Deactivate
deactivate# Modern alternative: pyproject.toml + tools like Poetry or PDM
# pyproject.toml (PEP 621 standard)
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"django>=4.2,<5.0",
"pydantic>=2.0",
"httpx>=0.25",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=23.0",
"mypy>=1.0",
]50. Walk me through Python performance profiling. How do you find a bottleneck?
# Step 1: Don't optimize before profiling. First, measure.
# cProfile — built-in, statement-level profiling
import cProfile
import pstats
import io
def slow_function():
return sorted([i % 100 for i in range(100_000)], reverse=True)
profiler = cProfile.Profile()
profiler.enable()
slow_function()
profiler.disable()
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream).sort_stats("cumulative")
stats.print_stats(10) # Top 10 functions by cumulative time
print(stream.getvalue())
# line_profiler — line-by-line timing (pip install line_profiler)
# @profile decorator added by the tool, removed before deployment
# timeit — micro-benchmarks
import timeit
# Compare two implementations
v1_time = timeit.timeit(
"[x**2 for x in range(1000)]",
number=10_000
)
v2_time = timeit.timeit(
"list(map(lambda x: x**2, range(1000)))",
number=10_000
)
print(f"List comp: {v1_time:.3f}s, map: {v2_time:.3f}s")
# Common optimization patterns after profiling reveals a bottleneck:
# 1. Replace O(n) list lookups with O(1) set/dict lookups
# 2. Use local variables inside hot loops (LEGB lookup is faster for locals)
# 3. Avoid repeated attribute lookups inside loops
# 4. Use generators instead of materializing large lists
# 5. Move to NumPy/pandas for numerical operations
# 6. Profile before reaching for Cython/C extensions51. What are Python's string formatting options? When do you use each?
name = "Ana"
score = 95.678
# 1. %-formatting — legacy, avoid in new code
print("Hello, %s! Score: %.2f" % (name, score))
# 2. str.format() — flexible, good for templates
print("Hello, {}! Score: {:.2f}".format(name, score))
print("Hello, {name}! Score: {score:.2f}".format(name=name, score=score))
# 3. f-strings (Python 3.6+) — preferred, fast, readable
print(f"Hello, {name}! Score: {score:.2f}")
# f-strings support arbitrary expressions
items = [1, 2, 3]
print(f"Total: {sum(items)}, Count: {len(items)}")
# Debugging with = (Python 3.8+)
x = 42
print(f"{x=}") # x=42
# Number formatting
big_number = 1_234_567.89
print(f"{big_number:,.2f}") # 1,234,567.89
print(f"{big_number:>20,.2f}") # right-aligned, width 20
# 4. Template strings — safe for user-provided templates (no code execution risk)
from string import Template
t = Template("Hello, $name! Your score is $score.")
print(t.safe_substitute(name=name, score=score))52. What are Python's walrus operator (`:=`) and structural pattern matching (`match`/`case`)?
# Walrus operator (:=) — assignment expression (Python 3.8+)
# Assigns and returns a value in one expression
# Without walrus
data = read_data()
if data:
process(data)
# With walrus — avoid calling read_data() twice
if data := read_data():
process(data)
# Very useful in while loops
import re
text = "Name: Ana, Age: 25, City: Buenos Aires"
while match := re.search(r'\d+', text):
print(f"Found: {match.group()}")
text = text[match.end():] # Advance past the match
# Structural Pattern Matching (Python 3.10+) — like switch/case, but far more powerful
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", item, "from", location]:
return f"Getting {item} from {location}"
case ["help", *topics]: # *topics captures remaining args
return f"Help on: {topics}"
case _:
return f"Unknown command: {command}"
print(handle_command("go north")) # "Going north"
print(handle_command("get sword from chest")) # "Getting sword from chest"
print(handle_command("help attack magic")) # "Help on: ['attack', 'magic']"How Interviewers Evaluate Your Answers
After sitting across from hundreds of candidates, strong Python interviewers are looking for three things beyond correct answers:
1. You know when NOT to use something. The best answers for __slots__, metaclasses, and deepcopy all include "and here's when you wouldn't bother." Knowing the tradeoff is more valuable than knowing the feature.
2. You can connect theory to production bugs. The mutable default argument, the GIL impact on I/O vs CPU, the need for functools.wraps — these come up because they burn people in real codebases.
3. You think in layers. For any question, there's a surface answer (30 seconds) and a deep answer (2 minutes). Practice both. Lead with the short answer, then offer to go deeper. This signals that you can read the room and don't need to dump everything you know to feel safe.
The Questions You Will Almost Certainly Get
Based on frequency across Python interviews at companies ranging from startups to FAANG, these are the ones you must be able to answer cold:
- 1What is the GIL and how does it affect threads? (question 4)
- 2Mutable default argument bug (question 1)
- 3Generator vs list comprehension (question 9)
- 4Decorator — write one from scratch (question 17)
- 5
deepcopyvscopy(question 5) - 6
classmethodvsstaticmethod(question 12) - 7How dict works internally (question 7)
- 8Closure definition and example (question 16)
- 9What is
__repr__vs__str__(question 11) - 10How
async/awaitdiffers from threads (question 23)
If you can answer those ten without hesitation, you're in the top quartile of candidates. The rest of this guide takes you into the top 10%.
Practice Strategy That Actually Works
Reading this guide gives you exposure. Answering the questions out loud — with the guide closed — gives you retrieval.
The gap between "I know this" and "I can explain this under pressure" is the gap that costs people job offers. Bridge it with three sessions:
Session 1 (Day 1): Read through all questions. Note which ones you couldn't explain fully.
Session 2 (Day 2): Cover the answers. Try to write the code for each flagged question from memory.
Session 3 (Day 3+): Have someone ask you questions randomly. Answer out loud. The act of explaining is different from reading — your brain routes the answer differently.
The questions that feel obvious when you're reading them are the ones that go blank when you're in the interview. That's not a knowledge problem. That's a retrieval problem. Solve it before the interview, not during.
