Complete module-wise interview preparation for core Python roles.
| Module | File | Questions |
|---|---|---|
| 01 Fundamentals | INTERVIEW.md | 28+ |
| 02 Data Structures | INTERVIEW.md | 25+ |
| 03 Functions & Modules | INTERVIEW.md | 25+ |
| 04 OOP | INTERVIEW.md | 24+ |
| 05 Data Handling | INTERVIEW.md | 27+ |
| 06 Intermediate | INTERVIEW.md | 28+ |
| 07 Advanced & Production | INTERVIEW.md | 38+ |
Total: 195+ questions with answers and code
Short answer: == checks value equality. is checks whether two variables point to the same object in memory.
Detailed explanation:
Python compares objects in two ways:
- Value comparison (
==) — calls__eq__()method. Two objects with same content return True. - Identity comparison (
is) — compares memory addresses usingid(). Same object → True.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True — same values
print(a is b) # False — different list objects in memory
print(a is c) # True — c references the same object as a
print(id(a), id(b), id(c)) # id(a) == id(c), id(b) is differentWhen to use is:
- Comparing with
None:if x is None(neverx == None) - Comparing singletons:
True,False
Integer caching trap (common follow-up):
x = 256; y = 256
print(x is y) # True — Python caches integers -5 to 256
x = 257; y = 257
print(x is y) # False — may differ (implementation dependent)Interview tip: Never use is for string or list comparison. Always use == for values.
Short answer: Mutable objects can be changed after creation. Immutable objects cannot.
Detailed explanation:
| Mutable | Immutable |
|---|---|
list |
int, float, bool |
dict |
str |
set |
tuple |
bytearray |
frozenset, bytes |
| custom objects | None |
# Mutable — change in place
nums = [1, 2, 3]
nums.append(4) # [1, 2, 3, 4] — same object
# Immutable — creates new object
s = "hello"
s.upper() # returns "HELLO" — s is still "hello"
# s[0] = "H" # TypeError: str does not support item assignmentWhy it matters:
- Hashability: Immutable types (if all elements hashable) can be dict keys and set elements. Lists cannot.
- Function defaults: Mutable defaults are shared (see Q10).
- Thread safety: Immutable objects are inherently thread-safe for reads.
- Memory: Immutable objects may be interned/cached (small ints, strings).
Follow-up: tuple is immutable but can contain mutable objects:
t = ([1, 2], 3)
t[0].append(99) # works — mutating inner list
# t[0] = [4, 5] # TypeError — cannot reassign tuple elementShort answer: Values that evaluate to False in boolean context.
Complete list:
falsy_values = [
None,
False,
0, # int zero
0.0, # float zero
0j, # complex zero
"", # empty string
[], # empty list
{}, # empty dict
set(), # empty set
(), # empty tuple
range(0), # empty range
bytes(), # empty bytes
]
for v in falsy_values:
print(f"{repr(v):12} → {bool(v)}") # all print FalseCustom falsy objects:
class Empty:
def __bool__(self):
return False
class ZeroLen:
def __len__(self):
return 0
bool(Empty()) # False — __bool__ takes priority
bool(ZeroLen()) # False — __len__ used if no __bool__Short-circuit evaluation:
result = expensive() and do_something() # do_something() skipped if expensive() is falsy
user = get_user() or default_user # default used if get_user() is falsyInterview tip: Everything not in the falsy list is truthy, including "0", "False", [0], [False].
Short answer: Both — Python compiles to bytecode, then interprets bytecode at runtime.
Detailed explanation:
Python execution pipeline:
Source (.py) → Compiler → Bytecode (.pyc) → Python Virtual Machine (PVM) → Output
- Compilation:
.pyfile compiled to bytecode stored in__pycache__/as.pycfiles. - Interpretation: PVM executes bytecode line by line at runtime.
import dis
def add(a, b):
return a + b
dis.dis(add)
# Shows bytecode instructions: LOAD_FAST, BINARY_ADD, RETURN_VALUEWhy called "interpreted":
- No ahead-of-time compilation to machine code (unlike C/C++).
- Bytecode runs through interpreter loop, not CPU directly.
CPython vs others:
- CPython — standard, has GIL, most common.
- PyPy — JIT compiler, faster for long-running programs.
- Cython — compiles to C extensions.
Follow-up: .pyc files speed up startup by skipping recompilation if source unchanged.
Detailed comparison:
| Feature | list | tuple | set | dict |
|---|---|---|---|---|
| Ordered | Yes | Yes | No* | Yes (3.7+) |
| Mutable | Yes | No | Yes | Yes |
| Duplicates | Yes | Yes | No | Keys: No |
| Indexed | Yes [0] |
Yes | No | By key |
| Lookup | O(n) | O(n) | O(1) avg | O(1) avg |
| Hashable | No | Yes* | No | No |
*Set iteration order is arbitrary. Tuple hashable if all elements hashable.
When to use each:
# LIST — ordered collection, allow duplicates, need to modify
shopping = ["apple", "banana", "apple"]
shopping.append("cherry")
# TUPLE — fixed data, return multiple values, dict keys
point = (10, 20)
person = ("Alice", 30, "Engineer")
locations = {(0, 0): "origin", (1, 2): "point A"}
# SET — unique items, membership testing, set math
unique_ids = {101, 102, 103, 101} # {101, 102, 103}
if user_id in valid_ids: # O(1) lookup
grant_access()
# DICT — key-value mapping, fast lookup by key
user = {"name": "Alice", "age": 30, "role": "admin"}
print(user["name"]) # O(1) lookupReal-world examples:
- list — queue of tasks, list of records from API
- tuple — database row, coordinates, function return values
- set — remove duplicates, find common elements between lists
- dict — JSON data, configuration, counting with keys
Short answer: Shallow copy duplicates the container only. Deep copy duplicates everything recursively.
Detailed explanation:
import copy
original = {
"name": "Alice",
"scores": [90, 85, 88],
"meta": {"active": True}
}
# Assignment — NOT a copy (same reference)
assigned = original
# Shallow copy — new outer dict, shared inner objects
shallow = original.copy() # or copy.copy(original)
shallow["scores"].append(100)
print(original["scores"]) # [90, 85, 88, 100] — AFFECTED!
# Deep copy — fully independent
original["scores"] = [90, 85, 88] # reset
deep = copy.deepcopy(original)
deep["scores"].append(100)
print(original["scores"]) # [90, 85, 88] — NOT affected
deep["meta"]["active"] = False
print(original["meta"]["active"]) # True — NOT affectedVisual:
Original: dict → [scores list] → [90, 85, 88]
Shallow: dict → [same scores list] → [90, 85, 88, 100]
Deep: dict → [new scores list] → [90, 85, 88, 100]
When to use:
- Shallow: Flat structures, no nested mutable objects.
- Deep: Nested lists/dicts where full independence needed.
Interview trap:
matrix = [[0]*3]*3 # BAD — all rows reference same list!
matrix[0][0] = 1 # changes ALL rows
# Correct: [[0]*3 for _ in range(3)]Problem: Given [2, 7, 11, 15] and target 9, return [0, 1] because 2 + 7 = 9.
Brute force — O(n²):
def two_sum_brute(nums, target):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]Optimal — O(n) time, O(n) space:
def two_sum(nums, target):
seen = {} # value → index
for i, n in enumerate(nums):
complement = target - n
if complement in seen:
return [seen[complement], i]
seen[n] = i
return [] # no solution
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum([3, 2, 4], 6)) # [1, 2]Walkthrough for [2, 7, 11, 15], target=9:
i=0, n=2: complement=7, not in seen → seen={2:0}
i=1, n=7: complement=2, in seen at index 0 → return [0, 1]
Follow-ups interviewers ask:
- What if no solution exists? Return
[]or raise exception. - What if duplicate values? Hash map stores latest index — still works.
- What if sorted array? Use two-pointer approach O(n) with O(1) space.
List operations:
| Operation | Time | Example |
|---|---|---|
| Access by index | O(1) | lst[5] |
| Append | O(1) amortized | lst.append(x) |
| Insert at index | O(n) | lst.insert(0, x) |
| Delete by index | O(n) | del lst[0] |
| Search (in) | O(n) | x in lst |
| Sort | O(n log n) | lst.sort() |
Dict / Set operations:
| Operation | Average | Worst Case |
|---|---|---|
| Lookup | O(1) | O(n) |
| Insert | O(1) | O(n) |
| Delete | O(1) | O(n) |
Worst case O(n) happens with many hash collisions (rare in practice).
# O(1) — use set for membership
VALID = {"E001", "E002", "E003"}
if emp_id in VALID: # fast even with millions of IDs
process()
# O(n) — list membership is slow for large lists
if emp_id in big_list: # scans entire list
process()Interview tip: Always mention average vs worst case for hash-based structures.
Short answer: Python's order for looking up variable names: Local → Enclosing → Global → Built-in.
Detailed explanation:
x = "global" # G — Global
def outer():
x = "enclosing" # E — Enclosing scope for inner()
def inner():
x = "local" # L — Local
print(x) # "local" — L found first
inner()
print(x) # "enclosing"
outer()
print(x) # "global"Modifying scopes:
count = 0
def increment():
global count # must declare to MODIFY global
count += 1
def outer():
total = 10
def inner():
nonlocal total # modify enclosing scope variable
total += 5
inner()
return total # 15Built-in scope example:
print(len([1, 2, 3])) # B — built-in len()
def demo():
len = "shadow" # L — shadows built-in len
print(len) # "shadow"Interview follow-up: What happens if variable not found in any scope? → NameError.
Short answer: Default arguments are evaluated once when the function is defined, not on each call.
The bug:
def add_item(item, lst=[]): # [] created ONCE at definition time
lst.append(item)
return lst
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] ← SURPRISE! Same list!
print(add_item("c")) # ['a', 'b', 'c']Why it happens:
# Equivalent to:
_default_list = []
def add_item(item, lst=_default_list):
lst.append(item)
return lst
# Every call without lst uses the SAME _default_list objectCorrect pattern:
def add_item(item, lst=None):
if lst is None:
lst = [] # new list created on each call
lst.append(item)
return lst
print(add_item("a")) # ['a']
print(add_item("b")) # ['b'] ← correct!Same trap with dict/set defaults:
# BAD
def configure(opts={}):
opts["debug"] = True
return opts
# GOOD
def configure(opts=None):
if opts is None:
opts = {}
opts["debug"] = True
return optsShort answer: A function that captures and remembers variables from its enclosing scope, even after the outer function has finished.
Detailed explanation:
def make_multiplier(factor):
# factor is "enclosed" in the closure
def multiply(number):
return number * factor # uses factor from enclosing scope
return multiply
times3 = make_multiplier(3)
times5 = make_multiplier(5)
print(times3(10)) # 30
print(times5(10)) # 50
# Each closure has its own captured `factor` valueHow closures work internally:
print(times3.__closure__) # tuple of cell objects
print(times3.__closure__[0].cell_contents) # 3Practical uses:
- Data encapsulation (private state):
def make_counter(start=0):
count = start
def increment(step=1):
nonlocal count
count += step
return count
return increment
counter = make_counter(10)
print(counter()) # 11
print(counter(5)) # 16- Decorators — closures capture the wrapped function.
- Callbacks — factory functions for event handlers.
Interview follow-up: Difference between closure and class? Closures are lighter for simple state; classes better for complex behavior.
Short answer: A decorator is a function that takes another function and extends its behavior without modifying the original.
Step by step:
from functools import wraps
# Step 1: Decorator is a function that accepts a function
def log_calls(func):
@wraps(func) # preserves func.__name__ and __doc__
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}")
result = func(*args, **kwargs) # call original
print(f"{func.__name__} returned {result}")
return result
return wrapper
# Step 2: @ syntax applies decorator
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
# @log_calls is equivalent to:
# add = log_calls(add)
print(add(3, 5))
# Calling add with (3, 5)
# add returned 8
# 8
print(add.__name__) # "add" (thanks to @wraps)Decorator with arguments:
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice") # prints 3 timesStacking decorators (applied bottom-up):
@decorator_a
@decorator_b
def func():
pass
# Equivalent to: func = decorator_a(decorator_b(func))Real-world uses: @app.route, @login_required, @lru_cache, @property, timing, logging, retry logic.
1. Encapsulation — Bundle data and methods together; hide internal details.
class BankAccount:
def __init__(self, balance):
self._balance = balance # "private" by convention
def deposit(self, amount):
if amount > 0:
self._balance += amount
def get_balance(self):
return self._balance
# Cannot do account._balance = 999999 without bypassing validation2. Abstraction — Show only essential features, hide complexity.
# User doesn't need to know HOW email sends
email_service.send("user@example.com", "Hello")
# Internal SMTP, TLS, retry logic hidden3. Inheritance — Child class inherits parent's attributes and methods.
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"4. Polymorphism — Same interface, different behavior.
def animal_sound(animal):
print(animal.speak()) # works for Dog, Cat, any Animal
animal_sound(Dog()) # Woof!
animal_sound(Cat()) # Meow!Python-specific: Also supports duck typing — "if it walks like a duck and quacks like a duck, it's a duck" — no inheritance required.
Short answer: The order Python searches for methods in a class hierarchy with multiple inheritance.
Detailed explanation:
Python uses C3 linearization algorithm for MRO.
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
d = D()
print(d.greet()) # "B" — first in MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)Diamond problem:
A
/ \
B C
\ /
D
MRO for D: D → B → C → A → object — each class appears once, parents before children.
Using super() with MRO:
class B(A):
def __init__(self):
super().__init__() # calls C.__init__ next (not A directly!)
print("B init")
class C(A):
def __init__(self):
super().__init__()
print("C init")
class D(B, C):
def __init__(self):
super().__init__()
print("D init")
D() # Output: A init, C init, B init, D initInterview tip: Always use super() instead of hardcoding parent class names.
Detailed comparison:
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# INSTANCE METHOD — receives self (the instance)
def display(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
# CLASS METHOD — receives cls (the class), used as factory
@classmethod
def from_string(cls, date_str):
year, month, day = map(int, date_str.split("-"))
return cls(year, month, day) # creates instance
# STATIC METHOD — no self or cls, utility in class namespace
@staticmethod
def is_valid(year, month, day):
return 1 <= month <= 12 and 1 <= day <= 31
# PROPERTY — computed attribute with getter/setter
@property
def is_leap_year(self):
y = self.year
return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
# Usage
d1 = Date(2026, 6, 21)
d2 = Date.from_string("2026-06-21") # classmethod factory
print(Date.is_valid(2026, 6, 21)) # staticmethod
print(d1.is_leap_year) # property (no parentheses)| Type | First arg | Use case |
|---|---|---|
| Instance method | self |
Operate on instance data |
@classmethod |
cls |
Alternative constructors, class-level ops |
@staticmethod |
none | Utility function grouped with class |
@property |
none | Computed/validated attributes |
Inheritance ("is-a") — child IS a type of parent.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Manager(Employee): # Manager IS an Employee
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self.bonus = bonusComposition ("has-a") — object CONTAINS another object.
class Engine:
def start(self):
return "Engine started"
class Car:
def __init__(self):
self.engine = Engine() # Car HAS an Engine
def start(self):
return self.engine.start()Why prefer composition:
- Flexibility: Change engine without changing Car hierarchy.
- Loose coupling: Engine can be reused in Truck, Boat.
- Avoid deep hierarchies:
FlyingSwimmingAnimalproblems.
Real-world example:
# BAD — deep inheritance
class AuthUser(AdminUser(PremiumUser(BaseUser))): pass
# GOOD — composition
class User:
def __init__(self):
self.auth = AuthService()
self.billing = BillingService()
self.permissions = PermissionManager()Interview answer: "Favor composition over inheritance unless a clear is-a relationship exists."
Short answer: No. Strings are immutable in Python.
Detailed explanation:
s = "hello"
print(id(s)) # memory address 1
s = s + " world" # creates NEW string object
print(id(s)) # memory address 2 — different object!
# Cannot modify in place:
# s[0] = "H" # TypeError
# s.append("!") # AttributeErrorAll string methods return NEW strings:
s = " Hello World "
print(s.strip()) # "Hello World" — s unchanged
print(s.lower()) # " hello world " — s unchanged
print(s) # " Hello World "Performance implication:
# BAD — O(n²) — creates new string each iteration
result = ""
for word in words:
result += word
# GOOD — O(n) — join once
result = "".join(words)String interning:
a = "hello"
b = "hello"
print(a is b) # True — Python may intern small/common stringsbytes vs str: Same immutability applies to bytes. Use bytearray for mutable binary data.
Complete flow:
def demo(mode):
try:
print("1. try block")
if mode == "error":
raise ValueError("Something went wrong")
print("2. try completed")
except ValueError as e:
print(f"3. except block: {e}")
else:
print("4. else block — runs ONLY if no exception")
finally:
print("5. finally block — ALWAYS runs")
demo("ok") # 1, 2, 4, 5
demo("error") # 1, 3, 5 (else skipped)Exception hierarchy:
BaseException
├── SystemExit, KeyboardInterrupt
└── Exception
├── ValueError, TypeError, KeyError
├── IOError, FileNotFoundError
└── Custom exceptions
Best practices:
# GOOD — catch specific exceptions
try:
value = int(user_input)
except ValueError:
print("Invalid number")
# BAD — bare except catches everything
try:
risky()
except: # catches KeyboardInterrupt too!
pass # silently swallows errors
# GOOD — exception chaining
try:
result = 1 / 0
except ZeroDivisionError as e:
raise RuntimeError("Calculation failed") from eelse block use case:
try:
data = json.loads(text)
except json.JSONDecodeError:
handle_bad_json()
else:
process(data) # only runs if JSON was validDetailed comparison:
| Feature | print() | logging |
|---|---|---|
| Output levels | No | DEBUG, INFO, WARNING, ERROR, CRITICAL |
| Turn off in production | Hard | Set level to WARNING |
| Output to file | Manual | Built-in handlers |
| Timestamp | Manual | Automatic |
| Module name | No | Yes |
| Log rotation | No | RotatingFileHandler |
| Thread safe | No | Yes |
logging setup (production standard):
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
handlers=[
logging.StreamHandler(), # console
logging.FileHandler("app.log"), # file
]
)
logger = logging.getLogger(__name__)
logger.debug("Detailed debug info") # hidden if level=INFO
logger.info("User logged in: alice") # shown
logger.warning("Disk space low: 10%") # shown
logger.error("Payment failed: timeout") # shown
logger.critical("Database unreachable") # shownLog levels usage:
- DEBUG — diagnostic info for developers
- INFO — normal operations (user login, request handled)
- WARNING — unexpected but handled (deprecated API, retry)
- ERROR — serious problem (failed transaction)
- CRITICAL — system failure (DB down, out of memory)
Rule: Never use print() in production code. Use logging.
Detailed comparison:
| List | Generator | |
|---|---|---|
| Syntax | [x**2 for x in range(n)] |
(x**2 for x in range(n)) |
| Memory | O(n) — stores all | O(1) — one item at a time |
| Speed (create) | Slower for large n | Instant |
| Speed (access) | O(1) random access | Sequential only |
| Reusable | Yes, many times | No — one iteration only |
| Use case | Small data, need indexing | Large/infinite data streams |
import sys
# Memory comparison
lst = [x**2 for x in range(1_000_000)]
gen = (x**2 for x in range(1_000_000))
print(sys.getsizeof(lst)) # ~8 MB
print(sys.getsizeof(gen)) # ~200 bytes
# Generator function
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
for num in count_up_to(5):
print(num) # 1, 2, 3, 4, 5Real-world generator use:
# Process 10GB file without loading into memory
def read_large_file(path):
with open(path) as f:
for line in f:
yield line.strip()
for line in read_large_file("huge.log"):
if "ERROR" in line:
handle_error(line)Infinite generator:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
print([next(fib) for _ in range(10)]) # first 10 Fibonacci numbersDetailed comparison:
# return — ends function completely
def with_return():
return 1
return 2 # never reached
print(with_return()) # 1
# yield — pauses function, returns value, keeps state
def with_yield():
yield 1
yield 2
yield 3
gen = with_yield()
print(next(gen)) # 1 — function paused after first yield
print(next(gen)) # 2 — resumes from where it paused
print(next(gen)) # 3
# next(gen) # StopIteration — generator exhaustedWhat yield preserves:
def stateful():
print("Start")
x = yield 1 # pauses here, returns 1
print(f"Got: {x}") # x = value sent via gen.send()
yield 2
gen = stateful()
print(next(gen)) # Start, then 1
print(gen.send(99)) # Got: 99, then 2yield from (delegate to sub-generator):
def chain():
yield from range(3)
yield from ["a", "b"]
print(list(chain())) # [0, 1, 2, 'a', 'b']When to use yield:
- Large datasets (memory efficiency)
- Infinite sequences
- Pipelines (producer-consumer pattern)
- Coroutines (before async/await)
Short answer: Object with __enter__() and __exit__() methods, used by with statement for guaranteed setup/cleanup.
Manual protocol:
class DatabaseConnection:
def __enter__(self):
print("Opening connection...")
self.conn = create_connection()
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
print("Closing connection...")
self.conn.close()
return False # don't suppress exceptions
with DatabaseConnection() as conn:
conn.execute("SELECT * FROM users")
# Connection always closed, even if exception occursBuilt-in context managers:
with open("file.txt", "w") as f: # file auto-closed
f.write("hello")
import threading
lock = threading.Lock()
with lock: # lock auto-released
shared_resource += 1@contextmanager (simpler):
from contextlib import contextmanager
@contextmanager
def timer(label):
import time
start = time.perf_counter()
yield
print(f"{label}: {time.perf_counter()-start:.4f}s")
with timer("Processing"):
process_data()exit parameters:
exc_type— exception class (None if no exception)exc_val— exception instanceexc_tb— traceback- Return
Trueto suppress exception,Falseto propagate.
Short answer: No. Type hints are for static analysis tools and documentation only.
Detailed explanation:
def greet(name: str, age: int) -> str:
return f"Hello {name}, you are {age}"
# All of these RUN without error at runtime:
greet("Alice", 30) # correct
greet(123, "thirty") # wrong types — runs fine!
greet(None, [1, 2]) # completely wrong — still runs!Static checking with mypy:
pip install mypy
mypy myfile.py # catches type errors BEFORE runtimeCommon type hints:
from typing import Optional, Union, List, Dict, Callable
def process(
name: str,
age: int,
tags: list[str], # list of strings
config: dict[str, bool], # dict with str keys, bool values
callback: Callable[[int], str], # function taking int, returning str
email: Optional[str] = None, # str or None
status: Union[int, str] = 0, # int or str (use int | str in 3.10+)
) -> bool:
return TruePython 3.10+ modern syntax:
def find_user(user_id: int) -> dict[str, str | int] | None:
...
def handle(value: int | float) -> str:
...Runtime validation (if needed): Use pydantic, beartype, or manual checks — not built into Python.
Short answer: A mutex in CPython that allows only one thread to execute Python bytecode at a time.
Detailed explanation:
Why GIL exists:
- Protects Python's memory management (reference counting) from race conditions.
- Simplifies CPython implementation.
- Makes C extensions easier to write.
Impact on performance:
# CPU-bound with threads — NO speedup (GIL blocks parallelism)
import threading
def cpu_task():
total = sum(i*i for i in range(10_000_000))
threads = [threading.Thread(target=cpu_task) for _ in range(4)]
# 4 threads run sequentially due to GIL — not 4x faster
# I/O-bound with threads — WORKS (GIL released during I/O wait)
def io_task():
response = requests.get("https://api.example.com") # GIL released while waitingDecision guide:
| Task type | Best approach | Why |
|---|---|---|
| I/O-bound (network, disk) | threading or asyncio |
GIL released during I/O wait |
| CPU-bound (computation) | multiprocessing |
Separate processes, separate GILs |
| Many concurrent connections | asyncio |
Single thread, event loop |
GIL release situations:
- I/O operations (file, network, sleep)
- NumPy/C extension computations (some release GIL)
time.sleep()
Interview follow-up: PyPy, Jython don't have GIL. CPython 3.13+ has experimental no-GIL mode (PEP 703).
Detailed comparison:
| Thread | Process | asyncio | |
|---|---|---|---|
| Memory | Shared | Separate | Shared (single thread) |
| Creation cost | Low | High | Very low |
| Communication | Shared memory | Queue, Pipe | await/async |
| Parallelism | GIL limited | True CPU parallel | Cooperative |
| Best for | I/O-bound | CPU-bound | Many I/O connections |
| Debugging | Hard (race conditions) | Medium | Easier (single thread) |
Threading example:
import threading
from concurrent.futures import ThreadPoolExecutor
def fetch_url(url):
# I/O wait — GIL released
return requests.get(url).text
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch_url, urls))Multiprocessing example:
from multiprocessing import Pool
def compute(n):
return sum(i*i for i in range(n))
with Pool(4) as pool:
results = pool.map(compute, [10_000_000]*4) # uses 4 CPU coresAsyncio example:
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
asyncio.run(main())When to choose:
- 1000 API calls → asyncio
- Image processing → multiprocessing
- Reading 5 files simultaneously → threading
- Web server (FastAPI/uvicorn) → asyncio
Unit test — tests ONE unit (function/class) in complete isolation.
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0Integration test — tests multiple components working together.
def test_user_registration_flow():
# Uses real/test database
user = create_user("alice@example.com")
token = login(user.email, "password")
profile = get_profile(token)
assert profile["email"] == "alice@example.com"Comparison:
| Unit Test | Integration Test | |
|---|---|---|
| Scope | Single function | Multiple components |
| Speed | Fast (ms) | Slower (seconds) |
| Dependencies | Mocked | Real (or test DB) |
| Catches | Logic bugs | Interface/config bugs |
| When run | Every commit | Before deploy |
Testing pyramid:
/ E2E Tests \ ← few, slow
/ Integration \
/ Unit Tests \ ← many, fast
pytest example:
import pytest
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expectedShort answer: Replace real dependencies with controlled fakes for isolated, fast, reliable tests.
Why mock:
- Don't hit real API/database in tests.
- Control return values and errors.
- Tests run fast and offline.
- Test error paths (API timeout, DB failure).
unittest.mock examples:
from unittest.mock import Mock, patch, MagicMock
# Mock object
mock_api = Mock()
mock_api.get_user.return_value = {"name": "Alice", "id": 1}
result = mock_api.get_user(1)
assert result["name"] == "Alice"
mock_api.get_user.assert_called_once_with(1)
# Patch external dependency
@patch("myapp.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"name": "Bob"}
user = fetch_user(42)
assert user["name"] == "Bob"
mock_get.assert_called_with("https://api.example.com/users/42")
# Patch with side effects
@patch("myapp.random.randint")
def test_dice_roll(mock_randint):
mock_randint.return_value = 6
assert roll_dice() == 6
# Simulate exceptions
mock_db.connect.side_effect = ConnectionError("DB down")
with pytest.raises(ServiceUnavailable):
get_users()What to mock: External APIs, databases, file system, time, random, email services.
What NOT to mock: The function you're actually testing.
Short answer: Declares fixed instance attributes — saves memory and speeds attribute access by eliminating __dict__.
Without slots:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# Each instance has __dict__ — ~48 bytes overhead per object
p = Point(1, 2)
p.z = 3 # can add any attribute dynamicallyWith slots:
class Point:
__slots__ = ("x", "y") # ONLY x and y allowed
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
# p.z = 3 # AttributeError — no __dict__ to store extra attrsMemory savings:
import sys
class Regular: pass
class Slotted:
__slots__ = ("x", "y")
def __init__(self, x, y): self.x, self.y = x, y
print(sys.getsizeof(Regular())) # ~48 bytes (empty __dict__)
print(sys.getsizeof(Slotted(1,2))) # ~32 bytes
# For 1 million objects: ~16 MB savedTrade-offs:
- ✅ Less memory, faster attribute access
- ❌ Cannot add attributes dynamically
- ❌ Cannot use
__dict__-based tools (some serialization libraries) - ❌ Multiple inheritance limitations
When to use: Classes with millions of instances (ORM models, data points, game entities).
Regular class (verbose):
class User:
def __init__(self, name: str, age: int, email: str = ""):
self.name = name
self.age = age
self.email = email
def __repr__(self):
return f"User(name={self.name!r}, age={self.age}, email={self.email!r})"
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return (self.name, self.age, self.email) == (other.name, other.age, other.email)Dataclass (same result, less code):
from dataclasses import dataclass, field
@dataclass
class User:
name: str
age: int
email: str = ""
tags: list[str] = field(default_factory=list) # mutable default fix
user = User("Alice", 30)
print(user) # User(name='Alice', age=30, email='', tags=[])Dataclass features:
@dataclass(frozen=True) # immutable instances
@dataclass(order=True) # auto __lt__, __gt__ for sorting
@dataclass
class Product:
price: float
def __post_init__(self): # validation after __init__
if self.price < 0:
raise ValueError("Invalid price")When to use dataclass: Data-holding objects (DTOs, config, API responses).
When to use regular class: Complex behavior, custom inheritance, frameworks.
Two mechanisms work together:
1. Reference Counting (primary)
import sys
x = [1, 2, 3] # refcount = 1
y = x # refcount = 2
print(sys.getrefcount(x) - 1) # 2 (subtract 1 for getrefcount arg)
del y # refcount = 1
del x # refcount = 0 → memory freed immediatelyEvery object tracks how many references point to it. When count hits zero, memory is deallocated instantly.
2. Garbage Collector (for circular references)
import gc
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b
b.ref = a # circular reference — refcount never hits 0
del a, b
gc.collect() # GC detects and cleans circular referencesMemory management summary:
Object created → refcount = 1
New reference → refcount += 1
del reference → refcount -= 1
refcount = 0 → memory freed immediately
Circular refs → garbage collector handles periodically
Memory optimization tips:
# Use __slots__ for many small objects
# Use generators instead of lists for large data
# Delete large objects explicitly: del big_list
# Use weakref for caches that shouldn't prevent GC
import weakref
cache = weakref.WeakValueDictionary()Interview follow-up: Python does NOT have manual memory management (no malloc/free). Developer doesn't allocate/deallocate — reference counting + GC handle it automatically.
# Two Sum — O(n)
def two_sum(nums, target):
seen = {}
for i, n in enumerate(nums):
if target - n in seen:
return [seen[target - n], i]
seen[n] = i
# Valid Palindrome
def is_palindrome(s):
s = "".join(c.lower() for c in s if c.isalnum())
return s == s[::-1]
# FizzBuzz
for i in range(1, 101):
print("FizzBuzz" if i % 15 == 0 else "Fizz" if i % 3 == 0 else "Buzz" if i % 5 == 0 else i)
# Flatten nested list
def flatten(lst):
for item in lst:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
# Timer decorator
from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter()-start:.4f}s")
return result
return wrapper
# LRU Fibonacci
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
# Group anagrams
from collections import defaultdict
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
groups[tuple(sorted(w))].append(w)
return list(groups.values())| Week | Focus | Action |
|---|---|---|
| 1 | Q1–Q10 | Fundamentals + data structures |
| 2 | Q11–Q20 | Functions, OOP, strings, generators |
| 3 | Q21–Q30 | Advanced, concurrency, memory |
| 4 | All 30 | Mock interview — answer without looking |
| 5–8 | Module INTERVIEW.md files | Deep dive per module |
- Clarify — ask about input size, edge cases, return type
- Think aloud — explain approach before coding
- Start simple — brute force first, then optimize with Big-O
- Test — walk through example + mention edge cases
- Know trade-offs — always explain why you chose your approach