Python Interview Questions and Answers: 60 Questions from Easy to Hard
This page helps Indian job seekers prepare for Python interviews for backend, data engineering and data analyst roles, whether you are a fresher facing your first technical round or an experienced developer aiming for a senior position. It has 60 questions in three levels, easy, medium and hard, each with a concise sample answer and, where it helps, runnable Python 3.12 code. Topics include data structures, OOP, generators, decorators, the GIL and concurrency, type hints, testing and performance. Answer each question aloud in your own words first, then compare with the sample answer and run the code to check your understanding.
What Python interviews cover
- Core language: data types, mutability, scope, functions, *args and **kwargs, comprehensions and exception handling, often tested through short predict-the-output snippets.
- Object-oriented Python: classes, inheritance and the MRO, dunder methods, dataclasses and properties, and when composition is a better choice than inheritance.
- Iterators, generators, decorators and context managers, which interviewers use to check whether you understand Python beyond basic syntax.
- Concurrency and performance: the GIL, threads versus processes versus asyncio, the time complexity of built-in structures and how you profile slow code.
- Practical engineering: type hints, pytest, mocking and virtual environments, plus pandas and NumPy for data roles, usually in a live coding round.
How freshers should prepare
- Write small programs every day in Python 3.12 or newer: string handling, list and dict problems, file reading and simple classes. Typing code beats only reading answers.
- Predict the output of tricky snippets on mutable defaults, is versus ==, shallow copies and closures, then run them to confirm your reasoning.
- Solve 40 to 60 easy and medium coding problems on arrays, strings, hashing and two pointers, and state the time and space complexity aloud as you go.
- Build one project you can explain end to end, such as a FastAPI or Flask REST API with a database, tests and a clear README on GitHub.
How experienced candidates should prepare
- Be ready to explain design choices from your own projects: why you chose asyncio or Celery, how you structured packages and what you would change now.
- Revise the internals that senior rounds probe: memory management, the GIL and free-threaded builds, descriptors, the MRO, and how dicts and generators behave.
- Prepare debugging and performance stories with real numbers, such as cutting an endpoint from 2 seconds to 200 ms by removing N+1 database queries.
- Review your testing strategy, typing with mypy or Pyright, packaging with pyproject.toml, and deployment basics such as Docker, Gunicorn or Uvicorn workers and logging.
Common mistakes to avoid
- Memorising definitions without running code. Interviewers change one detail in a snippet, and only hands-on practice lets you adapt with confidence.
- Claiming that threads speed up CPU-bound Python code. Explain the GIL correctly and say when you would choose processes or asyncio instead.
- Writing clever one-liners that are hard to read. Clear names, small functions and a quick test case impress interviewers more than compressed code.
- Ignoring edge cases such as empty input, None, duplicates and very large data. Mention them yourself before the interviewer has to ask.
- Listing libraries on your resume that you cannot discuss in depth, because every skill you list is fair game for detailed follow-up questions.
Python interview questions: Easy questions (freshers)
1. What is the difference between mutable and immutable types in Python?
A mutable object can be changed in place after it is created; an immutable one cannot. Lists, dicts, sets and most user-defined objects are mutable. int, float, str, tuple, frozenset and bytes are immutable, so any 'change' builds a new object and rebinds the name. This matters in three places: only hashable values, which in practice means immutable ones, can be dict keys or set members; a function that receives a list can modify the caller's list; and two names bound to the same list both see an append made through either name.
nums = [1, 2]
alias = nums
alias.append(3)
print(nums) # [1, 2, 3]: both names share one list
point = (1, 2)
saved = point
point += (3,)
print(saved) # (1, 2): += built a new tuple for point 2. What is the difference between a list and a tuple?
Both are ordered sequences that allow duplicates and indexing, but a list is mutable and a tuple is immutable. Use a list for a collection that grows or changes, such as rows read from a file. Use a tuple for a fixed record, such as a (latitude, longitude) pair or several values returned from a function. A tuple of hashable items is itself hashable, so it can be a dict key, for example {(2025, 'Q1'): 120}. Tuples are slightly smaller and faster to create, but the main reason to choose one is intent: a tuple tells the reader the shape is fixed.
3. What is the difference between is and ==?
== compares values by calling __eq__, while is checks identity: whether both names point to the very same object. Two separate lists with equal contents are == but not is. Use is only for singletons such as None, True, False and sentinel objects, as in if result is None. Never use is to compare numbers or strings. CPython caches small integers and interns some strings, so is can appear to work in a quick test and then fail with other values, and since Python 3.8 the compiler emits a SyntaxWarning when is is used with a literal.
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True: same contents
print(a is b) # False: two different objects
c = a
print(c is a) # True: same object 4. What are *args and **kwargs?
*args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. The names are only a convention; the * and ** do the work. They let a function accept a flexible number of inputs and are essential in wrappers and decorators that pass every argument through to another function. The same symbols unpack at the call site: f(*items) spreads a list into positional arguments and f(**options) spreads a dict into keyword arguments. Any parameter declared after *args can only be passed by keyword.
def log(level, *args, **kwargs):
print(level, args, kwargs)
log("INFO", "user", 42, city="Pune")
# INFO ('user', 42) {'city': 'Pune'}
params = {"city": "Delhi"}
log("WARN", *[1, 2], **params)
# WARN (1, 2) {'city': 'Delhi'} 5. Why is using a list as a default argument a bug?
Default values are evaluated once, when the def statement runs, not on every call. A default list is therefore a single shared object, and every call that mutates it sees the changes left by earlier calls. The fix is to use None as the default and create a fresh list inside the function. The same applies to dicts, sets and any other mutable default. Linters such as Ruff and Pylint flag this pattern. Interviewers like it because the output surprises anyone who expects a new list on each call.
def add_bad(item, bucket=[]):
bucket.append(item)
return bucket
print(add_bad(1), add_bad(2)) # [1, 2] [1, 2]
def add_good(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_good(1), add_good(2)) # [1] [2] 6. What is the difference between a list comprehension and a generator expression?
A list comprehension in square brackets builds the whole list in memory straight away. A generator expression in parentheses produces items lazily, one at a time, as they are requested. Use a list when you need to index the results, reuse them or take their length. Use a generator when the data is large or consumed once, for example sum(x * x for x in range(10_000_000)), which never holds ten million numbers at once. A generator can be iterated only once; a second pass over it yields nothing.
squares_list = [n * n for n in range(5)]
squares_gen = (n * n for n in range(5))
print(squares_list) # [0, 1, 4, 9, 16]
print(sum(squares_gen)) # 30
print(sum(squares_gen)) # 0: the generator is exhausted 7. How does a Python dictionary work, and what can be used as a key?
A dict is a hash table that maps keys to values with average O(1) lookup, insert and delete. Python hashes the key to find a slot, then uses == to confirm the match. A key must therefore be hashable, with a hash that never changes during its lifetime: str, int, tuples of hashable items and frozenset work, while list, dict and set raise TypeError. Since Python 3.7, dicts keeping insertion order is a language guarantee. Use dict.get(key, default) to avoid KeyError, and setdefault or collections.defaultdict when grouping values.
marks = {"asha": 82, "ravi": 74}
print(marks.get("meena", 0)) # 0
marks[("2025", "Q1")] = 1 # a tuple key is fine
try:
marks[["a"]] = 1
except TypeError as e:
print(e) # unhashable type: 'list' 8. What is the difference between a shallow copy and a deep copy?
A shallow copy creates a new outer container but reuses the same inner objects; a deep copy recursively copies everything. list.copy(), slicing with [:], dict.copy() and copy.copy() are all shallow, so if a list holds other lists, changing an inner list through the copy also changes the original. copy.deepcopy() avoids that, but it is slower and copies the whole object graph, so use it only when nested data must be fully independent, such as a settings template that you modify for each customer.
import copy
orig = [[1, 2], [3]]
shallow = orig.copy()
deep = copy.deepcopy(orig)
shallow[0].append(99)
print(orig) # [[1, 2, 99], [3]]
print(deep) # [[1, 2], [3]] 9. What does if __name__ == "__main__": do?
Every module has a __name__ attribute. When a file is run directly, for example python app.py, its __name__ is set to "__main__"; when the file is imported, __name__ is the module's own name. Code under this guard therefore runs only when the file is executed as a script, not when another module imports it. That keeps imports free of side effects, lets one file act as both a library and a command-line tool, and is required with multiprocessing on Windows and macOS, where child processes re-import the main module.
def main():
print("running as a script")
if __name__ == "__main__":
main() 10. How do try, except, else and finally work together?
Code that might fail goes in try. except handles a specific exception type; catch the narrowest type you can, because a bare except also swallows KeyboardInterrupt and SystemExit. else runs only when try raised nothing, which keeps the success path apart from the risky line. finally always runs, whether the block succeeded, failed or returned, so it holds cleanup such as closing a connection. In modern code a with statement often replaces finally for releasing resources.
def parse_age(text):
try:
age = int(text)
except ValueError:
print("not a number:", text)
return None
else:
print("parsed", age)
return age
finally:
print("done")
parse_age("29") # parsed 29, then done
parse_age("abc") # not a number: abc, then done 11. What is a lambda function, and when should you use one?
A lambda is a small anonymous function written as a single expression, such as lambda x: x * 2. It cannot contain statements like return, a for loop or an if block (a conditional expression is fine), and it returns the value of its expression automatically. Its natural use is a short throwaway function passed as an argument, especially the key for sorted(), min() or max(). If the logic needs a name, a docstring or more than one line, write a normal def; PEP 8 also advises against assigning a lambda to a variable.
employees = [("Asha", 72000), ("Ravi", 55000), ("Meena", 91000)]
by_salary = sorted(employees, key=lambda e: e[1], reverse=True)
print(by_salary[0]) # ('Meena', 91000) 12. What is a set, and when would you use one instead of a list?
A set is an unordered collection of unique, hashable items backed by a hash table. A membership test such as x in s is O(1) on average, against O(n) for a list, so a set is the right choice when you repeatedly check whether something exists, for example skipping IDs that were already processed. Sets also remove duplicates in one step and support union (|), intersection (&), difference (-) and symmetric difference (^). The trade-offs are no indexing, no guaranteed order and the requirement that items be hashable.
applied = {"asha", "ravi", "meena"}
shortlisted = {"ravi", "kiran"}
print(applied & shortlisted) # {'ravi'}
print(sorted(applied - shortlisted)) # ['asha', 'meena']
print(len(set([1, 1, 2, 3, 3]))) # 3 13. What are self and __init__ in a Python class?
__init__ is the initialiser that runs right after a new object is created, and it sets up the instance's attributes. Strictly speaking it is not the constructor, because __new__ creates the object first. self is the first parameter of every instance method and refers to the object the method was called on. Python passes it automatically, so acct.deposit(500) is the same as Account.deposit(acct, 500). The name self is only a convention, but every Python developer expects it.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
return self.balance
acct = Account("Asha")
print(acct.deposit(500)) # 500
print(Account.deposit(acct, 250)) # 750 14. What is the difference between a class attribute and an instance attribute?
A class attribute is defined in the class body and shared by all instances; an instance attribute is set on self and belongs to one object. Attribute lookup checks the instance first, then the class. Assigning through an instance, as in obj.attr = x, creates a new instance attribute that hides the class attribute instead of changing it. The classic bug is a mutable class attribute such as a list: appending through one instance changes it for every instance. Use class attributes for constants and shared settings, and instance attributes for per-object state.
class Cart:
tax_rate = 0.18 # class attribute
items = [] # shared by every Cart: a bug
a, b = Cart(), Cart()
a.items.append("pen")
print(b.items) # ['pen']
a.tax_rate = 0.05 # creates an instance attribute on a only
print(b.tax_rate) # 0.18 15. Why do you use a virtual environment, and how do you create one?
A virtual environment is an isolated folder with its own interpreter link and site-packages, so each project can pin its own library versions without clashing with other projects or the system Python. Create one with python -m venv .venv, activate it with source .venv/bin/activate on Linux or macOS (.venv\Scripts\activate on Windows), then install packages with pip. Record dependencies in requirements.txt or, in modern projects, pyproject.toml, and never commit the .venv folder. Tools such as uv and Poetry build on the same idea and add lock files.
16. What does the with statement do?
with runs a block inside a context manager, an object that has __enter__ and __exit__ methods. __enter__ runs at the start and can return a resource; __exit__ always runs at the end, even if the block raises an exception or returns early. Cleanup therefore becomes automatic: a file is closed, a lock is released, a database transaction is committed or rolled back. It replaces fragile try/finally code and is the standard way to open files, hold threading locks and manage database sessions.
import tempfile
from pathlib import Path
path = Path(tempfile.gettempdir()) / "demo.txt"
with open(path, "w", encoding="utf-8") as f:
f.write("hello")
print(f.closed) # True: closed automatically
print(path.read_text()) # hello 17. Write a function that checks whether a string is a palindrome, ignoring case, spaces and punctuation.
Normalise first, then compare the cleaned characters with their reverse. Keeping only characters where str.isalnum() is true removes spaces and punctuation, and casefold() is a stronger lower() that also handles cases such as the German letter ß. Slicing with [::-1] reverses a sequence. This runs in O(n) time with O(n) extra space. If the interviewer asks for O(1) extra space, use two indexes that move inward from both ends and skip characters that are not alphanumeric.
def is_palindrome(text: str) -> bool:
cleaned = [ch.casefold() for ch in text if ch.isalnum()]
return cleaned == cleaned[::-1]
print(is_palindrome("A man, a plan, a canal: Panama")) # True
print(is_palindrome("Python")) # False 18. Write code to count how often each word appears in a sentence.
collections.Counter is the idiomatic answer. It is a dict subclass that counts hashable items, and most_common(n) returns the n most frequent pairs in descending order of count. Lower-case the text and split it on whitespace first, so 'The' and 'the' count as one word. Interviewers often ask for a version without imports next: a plain dict with counts[word] = counts.get(word, 0) + 1 shows you understand what Counter does. Both approaches are O(n) in the number of words.
from collections import Counter
text = "The cat and the hat and the bat"
counts = Counter(text.lower().split())
print(counts["the"]) # 3
print(counts.most_common(2)) # [('the', 3), ('and', 2)] 19. What is a generator, and what does yield do?
A generator is a function that contains yield. Calling it does not run the body; it returns a generator object. Each next() call runs the body up to the next yield, hands that value out and pauses with all local variables kept, then resumes from the same spot on the following call. When the function ends, StopIteration is raised and a for loop stops cleanly. Because values are produced lazily, a generator uses constant memory for sequences of any length, even infinite ones.
def countdown(n):
while n > 0:
yield n
n -= 1
gen = countdown(3)
print(next(gen)) # 3
print(list(gen)) # [2, 1] 20. What is a decorator in Python?
A decorator is a callable that takes a function and returns a new function, usually a wrapper that adds behaviour before or after the original call. Writing @name above a def is shorthand for func = name(func). This works because functions are first-class objects that can be passed in and returned. Common uses are logging, timing, caching with functools.cache, permission checks and framework routes such as @app.get in FastAPI. Decorate the inner wrapper with functools.wraps so the original function's name and docstring survive.
import functools
def shout(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
return f"hello {name}"
print(greet("asha"), greet.__name__) # HELLO ASHA greet Python interview questions: Medium questions (1-3 years)
21. What is the GIL, and how does it affect multithreading in Python?
The Global Interpreter Lock in the standard CPython build is a mutex that lets only one thread execute Python bytecode at a time. Threads therefore give no speed-up for CPU-bound pure-Python work such as heavy loops. They still help with I/O-bound work, because a thread releases the GIL while it waits on the network, disk or a database, and many C extensions such as NumPy release it during heavy computation. For CPU-bound tasks, use multiprocessing or a ProcessPoolExecutor. Python 3.13 also introduced an optional free-threaded build without the GIL.
22. When would you choose threading, multiprocessing or asyncio?
Choose by the bottleneck. asyncio suits very high-concurrency I/O, such as thousands of HTTP calls or websocket connections, using one thread and cooperative coroutines, but it needs async-aware libraries such as httpx or asyncpg. Threads suit I/O-bound work with blocking libraries, such as running requests calls in parallel through a ThreadPoolExecutor. Multiprocessing suits CPU-bound work, since each process has its own interpreter and GIL, at the cost of start-up time and pickling data between processes. In web backends, heavy jobs often go to a task queue such as Celery instead.
import time
from concurrent.futures import ThreadPoolExecutor
def fetch(i):
time.sleep(0.2) # stands in for a network call
return i * 10
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(fetch, range(5)))
print(results, f"{time.perf_counter() - start:.1f}s") # [0, 10, 20, 30, 40] 0.2s 23. Write a decorator that logs how long a function takes, and explain why functools.wraps is needed.
The wrapper records time.perf_counter() before and after calling the original function, prints or logs the difference and returns the original result unchanged. Accepting *args and **kwargs lets it wrap any signature. Without functools.wraps, the decorated function's __name__, __doc__ and __module__ would describe the wrapper, which confuses logs, debuggers, documentation tools and frameworks that inspect functions. wraps copies that metadata and sets __wrapped__, so inspect.signature can still report the real parameters.
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def total(n): return sum(range(n))
print(total(1_000_000), total.__name__) # timing line, then 499999500000 total 24. How do you write a decorator that takes arguments, such as @retry(times=3)?
Add one more level of nesting. retry(times=3) is called first and must return the real decorator; that decorator receives the function and returns the wrapper. So the structure is factory, decorator, wrapper. In the wrapper, loop up to times, return on success and re-raise the last exception when the attempts run out. In production, add a delay with exponential backoff and retry only specific exception types such as connection errors or timeouts. Retrying on bugs like ValueError just hides real errors.
import functools
def retry(times=3, on=(ConnectionError,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except on:
if attempt == times:
raise
return wrapper
return decorator 25. What is the difference between an iterable and an iterator?
An iterable is anything you can loop over: it has __iter__, which returns an iterator. Lists, dicts, strings and files are iterables. An iterator is the object that does the stepping: it has __next__, which returns the next item or raises StopIteration, and its __iter__ returns itself. A list can be looped over many times because each for loop asks it for a fresh iterator, while an iterator is used up after one pass. A for loop simply calls iter() on the object and then next() until StopIteration.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
print(list(Countdown(3))) # [3, 2, 1]
it = iter([1, 2])
print(next(it), next(it)) # 1 2 26. How would you use generators to build a data-processing pipeline?
Chain small generator functions so each consumes the previous one lazily: one reads lines, the next parses them, the next filters them. Only one item moves through the chain at a time, so memory stays flat whatever the input size, and each stage can be tested alone. yield from delegates to another iterable or generator, which is handy for flattening nested sources such as several files. Nothing runs until the final consumer, such as a for loop, list() or sum(), starts pulling values.
def read_lines(lines):
yield from (line.strip() for line in lines)
def parse(rows):
for row in rows:
name, amount = row.split(",")
yield name, int(amount)
def big_orders(records, limit):
return (r for r in records if r[1] >= limit)
data = ["asha,1200\n", "ravi,300\n", "meena,5000\n"]
print(list(big_orders(parse(read_lines(data)), 1000)))
# [('asha', 1200), ('meena', 5000)] 27. What is the difference between an instance method, a classmethod and a staticmethod?
An instance method receives self and works with one object's state. A classmethod receives the class as cls, so it can create instances or read class-level data. Its main use is alternative constructors such as Employee.from_csv(row), which also work for subclasses because cls is the subclass. A staticmethod receives neither; it is a plain function placed in the class namespace because it belongs there logically, such as a validation helper. If a static method turns out to need the class, make it a classmethod.
class Employee:
def __init__(self, name, salary):
self.name, self.salary = name, salary
@classmethod
def from_csv(cls, row):
name, salary = row.split(",")
return cls(name, int(salary))
@staticmethod
def is_valid_salary(amount):
return amount > 0
emp = Employee.from_csv("Asha,72000")
print(emp.salary, Employee.is_valid_salary(-5)) # 72000 False 28. What is the time complexity of common list, dict and set operations?
For a list, indexing, append and pop() from the end are O(1), with append amortised. insert(0, x), pop(0), remove(x) and x in list are O(n), because elements shift or are scanned. For dicts and sets, get, set, delete and membership are O(1) on average and O(n) only in the rare worst case of many hash collisions. Sorting is O(n log n). Two common interview fixes are replacing a list with a set for repeated lookups, and replacing a list used as a queue with collections.deque, whose appendleft and popleft are O(1).
import timeit
from collections import deque
ids_list = list(range(100_000))
ids_set = set(ids_list)
print(timeit.timeit(lambda: 99_999 in ids_list, number=200) >
timeit.timeit(lambda: 99_999 in ids_set, number=200)) # True
queue = deque([1, 2, 3])
queue.appendleft(0)
print(queue.popleft(), list(queue)) # 0 [1, 2, 3] 29. What is the difference between __repr__ and __str__?
__str__ returns a readable string for end users and is used by print() and str(). __repr__ returns an unambiguous string for developers and is used by the REPL, by repr(), and when a container is printed, since a list shows the repr of its items. Ideally a repr looks like code that could recreate the object, such as Money(2500, 'INR'). If only __repr__ is defined, str() falls back to it, so write __repr__ first. Dataclasses generate a useful __repr__ automatically.
class Money:
def __init__(self, amount, currency="INR"):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self):
return f"Rs {self.amount}"
m = Money(2500)
print(m) # Rs 2500
print([m]) # [Money(2500, 'INR')] 30. What does @dataclass generate, and when would you use frozen=True or field(default_factory=...)?
@dataclass reads the class's annotated fields and generates __init__, __repr__ and __eq__, plus ordering methods when order=True. frozen=True makes instances immutable, raising FrozenInstanceError on assignment, and because eq and frozen are both true it also generates __hash__, so instances can be dict keys. A mutable default such as a list is rejected with ValueError because it would be shared between instances; use field(default_factory=list) to create a new one each time. slots=True, added in 3.10, gives instances __slots__ for lower memory use.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Point:
x: int
y: int
@dataclass
class Order:
id: int
items: list[str] = field(default_factory=list)
p = Point(1, 2)
print(p, {p: "home"}[Point(1, 2)]) # Point(x=1, y=2) home
print(Order(1).items is Order(2).items) # False 31. How does super() work with multiple inheritance, and what is the MRO?
The method resolution order is the sequence of classes Python searches for an attribute, computed with the C3 linearisation algorithm; you can inspect it with ClassName.__mro__. super() does not mean 'the parent class'. It means the next class after the current one in the MRO of the object's actual type. In a diamond hierarchy this lets every class run exactly once, provided each class calls super() and passes along keyword arguments it does not use. This cooperative style is how mixins work in Django and similar frameworks.
class Base:
def save(self): print("Base")
class Audit(Base):
def save(self): print("Audit"); super().save()
class Cache(Base):
def save(self): print("Cache"); super().save()
class Model(Audit, Cache):
def save(self): print("Model"); super().save()
Model().save() # Model, Audit, Cache, Base: each runs once
print([c.__name__ for c in Model.__mro__])
# ['Model', 'Audit', 'Cache', 'Base', 'object'] 32. What is Python's LEGB scope rule, and what do nonlocal and global do?
When Python reads a name, it searches four scopes in order: Local (the current function), Enclosing (any outer function), Global (the module) and Built-in. Assigning to a name anywhere in a function makes it local for the whole function, which is why reading it before the assignment raises UnboundLocalError. To rebind a variable of an outer function, declare it nonlocal; to rebind a module-level variable, declare it global. Mutating an outer list needs neither keyword, because that is not rebinding. Prefer returning values over using global.
count = 0
def make_counter():
total = 0
def increment():
nonlocal total
total += 1
return total
return increment
counter = make_counter()
print(counter(), counter()) # 1 2
def broken():
print(count) # calling broken() raises UnboundLocalError
count = 1 33. What does this print, and why: funcs = [lambda: i for i in range(3)]; print([f() for f in funcs])?
It prints [2, 2, 2], not [0, 1, 2]. Closures capture variables, not values. All three lambdas refer to the same variable i and look it up only when they are called, which happens after the loop has finished with i equal to 2. This is called late binding. The usual fix is to bind the current value as a default argument, lambda i=i: i, because defaults are evaluated when each lambda is created. functools.partial is a cleaner alternative when the function is more than a one-liner.
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in fixed]) # [0, 1, 2] 34. How do you write your own context manager?
Either write a class with __enter__ and __exit__, or, more simply, decorate a generator with contextlib.contextmanager. Code before yield is the setup, the yielded value is what as receives, and code after yield is the teardown. Wrap the yield in try/except or try/finally so the teardown also runs when the block raises. In a class, returning True from __exit__ suppresses the exception, which you should almost never do. Typical uses are a database transaction that commits or rolls back, a timer, or temporarily changing a setting.
from contextlib import contextmanager
@contextmanager
def transaction(log):
log.append("BEGIN")
try:
yield log
log.append("COMMIT")
except Exception:
log.append("ROLLBACK")
raise
steps = []
with transaction(steps):
steps.append("UPDATE")
print(steps) # ['BEGIN', 'UPDATE', 'COMMIT'] 35. How do you design custom exceptions, and what does raise ... from do?
Create a small hierarchy that subclasses Exception: one base class for your package, such as PaymentError, and specific subclasses such as InsufficientFunds or GatewayTimeout. Callers can then catch the precise case or the whole family. When you translate a low-level error into your own, use raise NewError(...) from err. It stores the original in __cause__ and the traceback shows both, which is vital for debugging. raise ... from None hides the original when it adds nothing. Never catch Exception just to log it and carry on silently.
class PaymentError(Exception):
"""Base error for the payments module."""
class GatewayTimeout(PaymentError):
pass
def charge(amount):
try:
raise TimeoutError("bank API took 30s")
except TimeoutError as err:
raise GatewayTimeout(f"charge of Rs {amount} failed") from err
try:
charge(500)
except PaymentError as e:
print(e, "| cause:", repr(e.__cause__)) 36. Are type hints enforced at runtime, and how do you write them in modern Python?
No. The interpreter stores annotations but does not check them, so a function annotated to take an int will accept a string. Static checkers such as mypy and Pyright check them in the editor and in CI, and some libraries, such as Pydantic and FastAPI, read them at runtime to validate data. Modern syntax uses built-in generics like list[int] and dict[str, float], the X | None union (3.10+) instead of Optional, and, since 3.12, type parameter syntax such as def first[T](items: list[T]) -> T and the type statement for aliases.
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
type UserId = int # type alias statement, Python 3.12+
def find(uid: UserId) -> str | None:
return {1: "Asha"}.get(uid)
print(first([10, 20]), find(2)) # 10 None
print(first("ab")) # a: wrong type, but no runtime error 37. What is the difference between an abstract base class and a typing.Protocol?
An ABC uses nominal typing: a class counts as a Gateway only if it explicitly inherits from it, and @abstractmethod prevents instantiating any subclass that has not implemented the required methods. A Protocol uses structural typing, sometimes called static duck typing: any class with matching methods satisfies it without inheriting anything, which suits third-party classes you cannot change. Protocols are checked by mypy or Pyright, not at runtime, unless marked @runtime_checkable, and even then isinstance only checks that the methods exist, not their signatures.
from abc import ABC, abstractmethod
from typing import Protocol
class Gateway(ABC):
@abstractmethod
def pay(self, amount: int) -> str: ...
class Notifier(Protocol):
def send(self, msg: str) -> None: ...
class Sms: # satisfies Notifier without inheriting from it
def send(self, msg: str) -> None:
print("SMS:", msg)
def alert(n: Notifier) -> None:
n.send("OTP sent")
alert(Sms()) # SMS: OTP sent; Gateway() would raise TypeError 38. How do you use pytest fixtures and parametrize?
A fixture is a function decorated with @pytest.fixture that builds something a test needs, such as a database session or a sample user. A test requests it simply by naming it as a parameter, and any code after a yield in the fixture runs as teardown. The scope argument (function, module or session) controls how often it is rebuilt. @pytest.mark.parametrize runs one test function with several input sets, each reported as a separate test, which replaces copy-pasted tests for edge cases such as zero, negative and boundary values.
import pytest
def apply_discount(price, pct):
if not 0 <= pct <= 100:
raise ValueError("bad pct")
return round(price * (100 - pct) / 100, 2)
@pytest.fixture
def cart_total():
return 1000
@pytest.mark.parametrize("pct, expected", [(0, 1000), (10, 900), (100, 0)])
def test_discount(cart_total, pct, expected):
assert apply_discount(cart_total, pct) == expected 39. How do you mock an external API call in a unit test, and where should you patch?
Use unittest.mock.patch, or pytest's monkeypatch, to replace the function that makes the network call with a mock whose return_value or side_effect you control, then assert on your code's behaviour and on how the mock was called. The key rule is to patch the name where it is looked up, not where it is defined: if orders.py does from payments import charge, patch 'orders.charge', because orders holds its own reference. Mock only boundaries such as HTTP, time and randomness; mocking your own internals makes tests brittle.
from unittest.mock import patch
def fetch_rate(currency): # imagine this calls a real API
raise RuntimeError("no network in tests")
def price_in_inr(usd):
return round(usd * fetch_rate("USD"), 2)
with patch(f"{__name__}.fetch_rate", return_value=83.5) as fake:
print(price_in_inr(10)) # 835.0
fake.assert_called_once_with("USD") 40. Which tools from the collections module do you use, and why?
defaultdict creates a default value for a missing key, which makes grouping a one-liner: groups[dept].append(name). Counter counts hashable items and offers most_common(). deque is a double-ended queue with O(1) appends and pops at both ends, ideal for breadth-first search or sliding windows, and maxlen turns it into a fixed-size buffer. namedtuple gives lightweight immutable records with field names, though typing.NamedTuple or a dataclass is often clearer in new code. OrderedDict matters less now that dicts keep order, but its move_to_end helps build LRU caches.
from collections import defaultdict, deque
rows = [("eng", "Asha"), ("hr", "Ravi"), ("eng", "Kiran")]
by_dept = defaultdict(list)
for dept, name in rows:
by_dept[dept].append(name)
print(dict(by_dept)) # {'eng': ['Asha', 'Kiran'], 'hr': ['Ravi']}
recent = deque(maxlen=3)
for page in ["home", "jobs", "login", "profile"]:
recent.append(page)
print(list(recent)) # ['jobs', 'login', 'profile'] Python interview questions: Hard questions (experienced)
41. How does CPython manage memory, and can a Python program leak memory?
CPython frees most objects through reference counting: each object counts the references to it and is deallocated the moment the count reaches zero. Reference cycles, such as two objects pointing at each other, never reach zero, so a cyclic garbage collector (the gc module) periodically finds and frees unreachable groups. Small objects come from the pymalloc allocator, which can keep freed memory for reuse rather than returning it to the operating system. Leaks still happen in practice through ever-growing module-level caches, unbounded lru_cache, global registries and closures that hold large objects. Compare tracemalloc snapshots to find them.
import gc
class Node:
def __init__(self):
self.other = None
gc.collect()
a, b = Node(), Node()
a.other, b.other = b, a # a reference cycle
del a, b # the counts never reach zero
print(gc.collect() >= 2) # True: the cycle collector frees both 42. What is free-threaded Python, and should you switch to it?
Free-threaded CPython (PEP 703) is a separate build, such as python3.13t, compiled without the GIL, so threads can run Python bytecode on several cores at once. It arrived as experimental in 3.13 and became officially supported, though still optional, in 3.14; the default build keeps the GIL. Single-threaded code runs somewhat slower on it, and C extensions must be built for it and declare support, otherwise importing one turns the GIL back on. Adopt it only after benchmarking a CPU-bound threaded workload, confirming every dependency supports it and reviewing shared state for proper locking.
43. What happens if you call a blocking function inside an async endpoint, and how do you fix it?
The event loop runs on one thread, so a blocking call such as time.sleep, requests.get or a synchronous database driver freezes it. Every other request on that loop waits until the call returns, and latency spikes under load. Fixes, in order of preference: use an async library such as httpx.AsyncClient or asyncpg; run the blocking call in a thread with await asyncio.to_thread(func, ...); or move CPU-heavy work to a process pool. In FastAPI a plain def endpoint already runs in a thread pool, so a blocking call inside async def is a common production bug.
import asyncio, time
def blocking_io(n):
time.sleep(0.5) # for example, a synchronous SDK call
return n
async def main():
start = time.perf_counter()
results = await asyncio.gather(
*(asyncio.to_thread(blocking_io, i) for i in range(4)))
print(results, f"{time.perf_counter() - start:.1f}s") # [0, 1, 2, 3] 0.5s
asyncio.run(main()) 44. How does asyncio.TaskGroup differ from asyncio.gather?
TaskGroup, added in 3.11, gives structured concurrency: tasks created inside the async with block cannot outlive it. If any task fails, the group cancels the remaining tasks, waits for them, and raises an ExceptionGroup holding every failure, which you handle with except*. gather with default settings passes the first exception to the caller but leaves the other tasks running in the background, which can leak work and hide errors; with return_exceptions=True it returns errors as results instead. For new code, TaskGroup is the safer default.
import asyncio
async def fail_after(delay):
await asyncio.sleep(delay)
raise ValueError("orders failed")
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fail_after(0.1))
slow = tg.create_task(asyncio.sleep(5))
except* ValueError as eg:
print([str(e) for e in eg.exceptions], slow.cancelled()) # ['orders failed'] True
asyncio.run(main()) 45. What is a descriptor, and how does @property use it?
A descriptor is an object stored as a class attribute that defines __get__ and optionally __set__ or __delete__; attribute access on instances is then routed through those methods. property, classmethod, staticmethod and even plain functions, which become bound methods through __get__, are descriptors. Precedence matters: a data descriptor, one with __set__ or __delete__, wins over the instance __dict__, while a non-data descriptor with only __get__ loses to it. __set_name__ tells the descriptor its attribute name, which makes reusable field validators easy, as in ORMs.
class Positive:
def __set_name__(self, owner, name):
self.private = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.private)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.private[1:]} must be positive")
setattr(obj, self.private, value)
class Invoice:
amount = Positive()
def __init__(self, amount):
self.amount = amount # runs Positive.__set__
print(Invoice(500).amount) # 500; Invoice(-1) raises ValueError 46. What does __slots__ do, and what are its trade-offs?
Declaring __slots__ = ('x', 'y') tells Python to store those attributes in fixed slots instead of a per-instance __dict__. Instances use less memory and attribute access is slightly faster, which matters when you hold millions of small objects. The trade-offs: you cannot add attributes that are not listed, there is no __dict__ unless you list it, weak references need '__weakref__' in the slots, and every class in the hierarchy must define __slots__ or the saving is lost. Recent CPython versions store instance dicts more compactly, so measure the gain first.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
try:
p.z = 3
except AttributeError:
print("cannot add z") # attributes are fixed
print(hasattr(p, "__dict__")) # False 47. When would you use a metaclass, and what simpler alternatives exist?
A metaclass is the class of a class, type by default. A custom metaclass can intercept class creation to register, validate or change classes, which is how ABCMeta and some ORMs work. Application code rarely needs one. __init_subclass__ runs whenever a subclass is defined and covers the common cases of plugin registries and validation. Class decorators handle one-off changes, and __set_name__ on descriptors handles per-field setup. Metaclasses also conflict when a class inherits from two bases with unrelated metaclasses, another reason to prefer the simpler hooks.
class Exporter:
registry = {}
def __init_subclass__(cls, fmt, **kwargs):
super().__init_subclass__(**kwargs)
Exporter.registry[fmt] = cls
class CsvExporter(Exporter, fmt="csv"):
pass
class PdfExporter(Exporter, fmt="pdf"):
pass
print(Exporter.registry["pdf"].__name__) # PdfExporter
print(type(Exporter)) # <class 'type'> 48. What is the difference between __new__ and __init__, and when do you need __new__?
__new__ creates and returns the instance; __init__ then initialises it and must return None. If __new__ returns an object that is not an instance of the class, __init__ is skipped. You rarely override __new__, but it is required when subclassing immutable types such as int, str or tuple, because their value is fixed at creation and cannot be set in __init__. It is also used for instance caching. For a singleton, a module-level instance is usually simpler and easier to test than overriding __new__.
class Rupees(int):
def __new__(cls, value):
if value < 0:
raise ValueError("amount cannot be negative")
return super().__new__(cls, round(value))
def __repr__(self):
return f"Rupees({int(self)})"
price = Rupees(199.6)
print(repr(price), price + 1) # Rupees(200) 201 49. If you override __eq__, what happens to __hash__, and why does it matter?
Defining __eq__ in a class without also defining __hash__ sets __hash__ to None, so instances become unhashable and putting them in a set or using them as dict keys raises TypeError. Python does this to protect the rule that objects which compare equal must have equal hashes; otherwise sets and dicts would hold duplicates or miss lookups. If you need hashing, implement __hash__ from the same immutable fields used in __eq__, typically hash((self.a, self.b)). Never hash mutable fields: changing one after insertion makes the object impossible to find in its set.
class Emp:
def __init__(self, code):
self.code = code
def __eq__(self, other):
return isinstance(other, Emp) and self.code == other.code
print(Emp.__hash__) # None
try:
{Emp("E1")}
except TypeError as e:
print(e) # unhashable type: 'Emp' 50. What happens when you run t = ([1, 2],) and then t[0] += [3]?
It raises TypeError: 'tuple' object does not support item assignment, yet the list inside has already become [1, 2, 3]. Augmented assignment on a subscript is two steps. First it calls list.__iadd__, which extends the list in place and succeeds. Then it tries to store the result back with t[0] = ..., which fails because tuples are immutable. The lesson is that += combines mutation with rebinding and is not a single operation. Use t[0].extend([3]), which only mutates and never attempts the assignment.
t = ([1, 2],)
try:
t[0] += [3]
except TypeError as e:
print("TypeError:", e)
print(t) # ([1, 2, 3],) 51. What happens to a generator that is abandoned before it finishes, and how does its cleanup code run?
A paused generator keeps its frame alive, including any open file or connection. When you call close(), or when the generator is garbage-collected, Python raises GeneratorExit at the paused yield, so try/finally blocks and with statements inside it run their cleanup. Two pitfalls: if the generator catches GeneratorExit and yields again, Python raises RuntimeError; and relying on garbage collection makes the timing unpredictable, for example when a traceback still holds a reference. For generators that hold resources, close them explicitly or wrap them in contextlib.closing.
from contextlib import closing
def read_rows():
print("open connection")
try:
for i in range(1_000):
yield i
finally:
print("close connection")
with closing(read_rows()) as rows:
for row in rows:
if row == 2:
break # prints: open connection, close connection 52. Implement an LRU cache with O(1) get and put.
Use an OrderedDict, which combines a hash map with a doubly linked list. get moves the key to the end with move_to_end, so the end always holds the most recently used item. put inserts or updates the key, moves it to the end and, if the size exceeds capacity, removes the oldest item with popitem(last=False). All operations are O(1). If the interviewer forbids OrderedDict, build the same structure from a dict plus a hand-written doubly linked list. In real code, functools.lru_cache(maxsize=...) caches function results, but it needs hashable arguments.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity, self.data = capacity, OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False) 53. Is counter += 1 thread-safe in Python? How do you make shared state safe?
No. counter += 1 is several bytecode steps: load the value, add one, store the result. A thread switch between the load and the store lets two threads write the same value, losing an increment. The GIL protects the interpreter's internals, not your read-modify-write sequences, and the free-threaded build removes even that coarse protection. Guard the critical section with threading.Lock, hand work to a single owner thread through queue.Queue, or avoid shared mutable state. Keep the locked section small to limit contention.
import threading
counter = 0
lock = threading.Lock()
def work():
global counter
for _ in range(100_000):
with lock:
counter += 1
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # always 400000 with the lock 54. How do you type-hint a decorator so that the decorated function keeps its signature?
Use a ParamSpec for the parameters and a type variable for the return type. Typing the decorator as taking Callable[P, R] and returning Callable[P, R] tells mypy and Pyright that the wrapper accepts exactly the same arguments, so calls with wrong arguments are still caught. A decorator typed with Callable[..., Any] erases the signature and lets every call pass type checking. With Python 3.12+ type parameter syntax, def logged[**P, R](...) declares both inline. For a decorator that injects an extra argument, use Concatenate.
import functools
from collections.abc import Callable
def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@logged
def transfer(amount: int, to: str) -> bool:
return amount > 0
print(transfer(500, to="ACC-9")) # calling transfer, then True 55. When would you use a TypedDict, a dataclass or a Pydantic model for API data?
A TypedDict only describes the shape of a plain dict for static checkers; at runtime it is an ordinary dict with no validation, which makes it a cheap way to type existing JSON-like data. A dataclass is a real class with generated methods, but it does not validate types either: OrderDC(qty='ten') is accepted. A Pydantic model validates and coerces input at runtime and produces clear errors, so it belongs at trust boundaries such as request bodies, webhooks and configuration. A common design is Pydantic at the edges and plain dataclasses inside the service.
from dataclasses import dataclass
from pydantic import BaseModel, ValidationError
@dataclass
class OrderDC:
qty: int
class OrderIn(BaseModel):
qty: int
print(OrderDC(qty="ten")) # OrderDC(qty='ten'): no check
print(OrderIn(qty="5").qty + 1) # 6: coerced to int
try:
OrderIn(qty="ten")
except ValidationError as e:
print(e.error_count(), "validation error") # 1 validation error 56. How do you make code easy to test, and when do mocks become a problem?
Keep business logic in functions or classes that receive their dependencies, such as a clock, an HTTP client or a repository, as parameters instead of creating them inside. Tests can then pass simple fakes and need few patches. Heavy mocking is a warning sign: tests that assert exact internal call sequences break on every refactor, yet still pass when the real integration is broken. Mock only at system boundaries, keep some integration tests against a real database, for example in a container, and inject a clock rather than patching datetime everywhere.
from collections.abc import Callable
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def is_otp_expired(issued_at: datetime, ttl_seconds: int = 300,
now: Callable[[], datetime] = utc_now) -> bool:
return (now() - issued_at).total_seconds() > ttl_seconds
issued = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
later = lambda: datetime(2026, 1, 1, 10, 6, tzinfo=timezone.utc)
print(is_otp_expired(issued, now=later)) # True: 6 minutes > 5 57. A Python API endpoint is slow. How do you find and fix the bottleneck?
Measure before changing anything. Request timings or APM traces show where the time goes, and in backends it is usually the database or external calls rather than Python itself, so check query counts for N+1 patterns and the slow-query log first. For Python CPU time, profile with cProfile or attach a sampling profiler such as py-spy to the live process, then sort by cumulative time. Fixes, roughly in order of payoff: a better algorithm or data structure, fewer queries, caching, batched I/O, then vectorising with NumPy. Re-measure after each change.
import cProfile, pstats
def slow_unique(items):
seen = []
for x in items:
if x not in seen: # O(n) scan inside a loop: O(n^2) overall
seen.append(x)
return seen
with cProfile.Profile() as profiler:
slow_unique(list(range(3000)) * 2)
pstats.Stats(profiler).sort_stats("cumulative").print_stats(3) 58. Why is looping over a pandas DataFrame row by row slow, and what do you do instead?
iterrows() and apply(axis=1) run Python code once per row and build a Series object for each row, so you pay interpreter overhead millions of times. Vectorised column operations push the loop into optimised C code inside NumPy and pandas and are commonly tens to hundreds of times faster. Replace row loops with column arithmetic, np.where or np.select for conditions, map for lookups, and groupby with built-in aggregations. For files too large for memory, read them in chunks with chunksize, load only needed columns with usecols and use compact dtypes such as category.
import numpy as np
import pandas as pd
df = pd.DataFrame({"salary": [30000, 85000, 120000],
"city": ["Pune", "Delhi", "Pune"]})
# slow: df.apply(lambda r: r.salary * 0.1 if r.salary > 50000 else 0, axis=1)
df["bonus"] = np.where(df["salary"] > 50000, df["salary"] * 0.1, 0)
print(df.groupby("city")["bonus"].sum().to_dict())
# {'Delhi': 8500.0, 'Pune': 12000.0} 59. What causes a circular import error, and how do you fix it properly?
When module a imports b and b, at its top level, imports a, Python finds a only partly initialised in sys.modules. If b then reads a name that a has not defined yet, you get ImportError: cannot import name ... from partially initialized module, or an AttributeError. The proper fix is structural: move the shared code into a third module that both import, or reverse a dependency so layers point one way, for example models never importing services. Tactical fixes are importing inside the function that needs it, or importing for type hints only under if TYPE_CHECKING.
60. How is a Python dict implemented, and when can lookups become slow?
CPython's dict is a hash table with open addressing. Since 3.6 it uses a compact layout: a dense array of (hash, key, value) entries in insertion order plus a smaller sparse index table pointing into it, which saves memory and makes ordering cheap. A lookup hashes the key, probes the index table and checks identity and the stored hash before calling ==. The table resizes when about two-thirds full, so inserts are amortised O(1). Lookups degrade towards O(n) when many keys collide, for example with a __hash__ that returns a constant. String hashes are randomised per process to block collision attacks.
import timeit
class BadKey:
def __init__(self, v): self.v = v
def __hash__(self): return 1 # every key collides
def __eq__(self, other): return self.v == other.v
good = {i: i for i in range(1000)}
bad = {BadKey(i): i for i in range(1000)}
print(timeit.timeit(lambda: good[999], number=200) <
timeit.timeit(lambda: bad[BadKey(999)], number=200)) # True Ready to apply?
There are 3,627 open jobs asking for Python on CoreHRx right now. Create a free profile once and apply with it.