Python Interview Questions and Answers
Last updated:
Check out 113 of the most common Python interview questions, then take an AI-powered practice interview
Q1What is Python and what are its key features?
BasicBasics
Answer
Python is a high-level, general-purpose programming language that is dynamically typed, garbage-collected and interpreted through bytecode. Its defining features are readable syntax with significant indentation, a large standard library, first-class functions, support for procedural, object-oriented and functional styles, and an ecosystem that covers web backends, data science, machine learning, automation and scripting.
In an interview, do not stop at the textbook list. Say which feature you have actually used: comprehensions and generators for data pipelines, the standard library (json, pathlib, collections, itertools) for scripts without dependencies, and duck typing for flexible code. Mention that CPython is the reference implementation and that PyPy, GraalPy and the free-threaded CPython build exist, so 'Python' usually means CPython unless stated otherwise.
A good closing line: Python trades raw execution speed for developer speed, and the usual answer to a hot loop is a C-backed library such as NumPy, not rewriting the whole program.
# Four features in eight lines: dynamic typing, first-class functions,
# comprehensions and a rich standard library.
from collections import Counter
def top_words(text, n=2):
words = [w.strip('.,!').lower() for w in text.split()]
return Counter(words).most_common(n)
print(top_words('Python is simple. Python is powerful!'))
# [('python', 2), ('is', 2)]
Key Points
- High-level, dynamically typed, garbage-collected, compiled to bytecode then interpreted
- Multi-paradigm: procedural, object-oriented and functional in one language
- Huge standard library plus PyPI; NumPy, pandas, Django, FastAPI, pytest are the common follow-ups
- CPython is the reference implementation; the GIL and bytecode cache are CPython details
Q2What are Python's data types?
BasicData Types
Answer
Python's built-in data types fall into six groups: numeric (int, float, complex, and bool as an int subclass), text (str), sequences (list, tuple, range), binary (bytes, bytearray, memoryview), mapping (dict), and sets (set, frozenset), plus the NoneType singleton None. The follow-up is always which of these are mutable: list, dict, set and bytearray are; the rest are not.
Every value in Python is an object, so even an int has methods and a type. Variables are names bound to objects, not typed boxes, which is why type(x) can change when you rebind x. Integers have arbitrary precision (2 ** 200 just works), floats are IEEE 754 doubles (0.1 + 0.2 != 0.3), and decimal.Decimal and fractions.Fraction exist for exact arithmetic.
When an interviewer asks this at a fresher round, they want the groups, one example of each, and the mutable versus immutable split. At a product company they want you to go one step further: hashability (dict keys and set members must be immutable in practice), and the collections module (deque, defaultdict, Counter, namedtuple) as the next tier of types you reach for.
values = [42, 3.14, 2 + 3j, True, 'text', [1, 2], (1, 2), range(3),
b'bytes', bytearray(b'ab'), {'k': 'v'}, {1, 2}, frozenset({1}), None]
for v in values:
print(f'{type(v).__name__:<10} {v!r}')
print(2 ** 200) # arbitrary-precision int, no overflow
print(0.1 + 0.2 == 0.3) # False: binary floating point
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3')) # True
# Hashability decides what can be a dict key or set member
print(hash((1, 2))) # tuples are hashable
try:
hash([1, 2])
except TypeError as e:
print('list:', e) # unhashable type: 'list'
Key Points
- Groups: numeric, text, sequence, binary, mapping, set, plus None
- Mutable: list, dict, set, bytearray. Immutable: int, float, str, tuple, frozenset, bytes
- int is arbitrary precision; float is IEEE 754, use Decimal for money
- collections adds deque, defaultdict, Counter, namedtuple; dataclasses add records
Q3What is the difference between a list and a tuple?
BasicData Types
Answer
A list is a mutable sequence: you can append, remove, sort in place and assign to indices. A tuple is an immutable sequence: once created, its length and elements cannot change. Lists use square brackets, tuples use parentheses (or just commas), and both are ordered, indexable, sliceable and allow duplicates.
The practical consequences matter more than the syntax. Because a tuple is immutable, it is hashable when its contents are, so it can be a dict key or a set member; a list cannot. Tuples are slightly smaller in memory and the interpreter can create constant tuples at compile time. Lists have a larger API (append, extend, insert, pop, sort, reverse) and over-allocate so that repeated appends are amortised O(1).
Use a tuple for a fixed record of heterogeneous fields (a coordinate, a database row, a function returning several values) and a list for a homogeneous collection that grows or changes. A common trap: a tuple holding a list is still 'immutable' at the top level, but the inner list can be mutated, so immutability is shallow.
point = (12.9, 77.6) # tuple: fixed record
skills = ['python', 'sql'] # list: grows
skills.append('django')
try:
point[0] = 0
except TypeError as e:
print(e) # 'tuple' object does not support item assignment
cache = {point: 'Bengaluru'} # tuple works as a dict key
try:
cache[skills] = 'x'
except TypeError as e:
print(e) # cannot use 'list' as a dict key (unhashable type: 'list')
import sys
print(sys.getsizeof((1, 2, 3)), sys.getsizeof([1, 2, 3])) # tuple is smaller
t = ([1, 2], 'fixed')
t[0].append(3) # inner list still mutates
print(t) # ([1, 2, 3], 'fixed')
Key Points
- List: mutable, bigger API, over-allocates for amortised O(1) append
- Tuple: immutable, hashable if contents are, usable as dict key and set member
- Tuple for fixed heterogeneous records, list for homogeneous growing collections
- Immutability is shallow: a tuple of lists still has mutable elements
Q4Which built-in types are mutable and which are immutable, and why does it matter for function arguments?
BasicData Types
Answer
Immutable built-ins: int, float, bool, str, bytes, tuple, frozenset, and None. Mutable built-ins: list, dict, set, bytearray, and almost every user-defined class. The distinction matters because Python passes references to objects, not copies.
When you pass a list into a function and the function calls .append() on it, the caller's list changes, because both names point at the same object. With an immutable type that cannot happen: any 'modification' of a string or tuple creates a new object and rebinds the local name, leaving the caller untouched. This is also why only immutable (more precisely, hashable) objects can be dict keys or set members: the hash must never change during the object's lifetime.
A classic probe is the difference between `a += b` and `a = a + b` on a list: += calls __iadd__ which mutates in place (callers see it), while `a = a + b` builds a new list and rebinds only the local name. The same operator on a tuple raises no error but creates a new tuple. There is a famous corner case interviewers love: a tuple containing a list, like t = ([1], ), where `t[0] += [2]` both mutates the inner list AND raises TypeError, because the in-place mutation succeeds before the tuple assignment fails. Understanding mutability is the foundation for three later topics: mutable default arguments, shallow versus deep copy, and why dict keys must be hashable.
def sneaky(items):
items.append('added') # mutates the caller's list
items = ['rebound'] # only rebinds the local name
return items
original = ['a', 'b']
result = sneaky(original)
print(original) # ['a', 'b', 'added'] <- caller affected by append
print(result) # ['rebound']
# The classic += trap inside a tuple:
t = ([1, 2],)
try:
t[0] += [3]
except TypeError as e:
print(e) # 'tuple' object does not support item assignment
print(t) # ([1, 2, 3],) <- the list STILL mutated
Q5Explain `is` versus `==`, and why `a is b` can be True for small integers but False for large ones.
BasicData Types
Answer
`==` calls __eq__ and compares values; `is` compares object identity, meaning both names point to the exact same object in memory (same id()). The correct uses of `is` are narrow: comparisons against singletons, `x is None`, `x is NotImplemented`, `x is Ellipsis`, and sentinel objects you created yourself. Everything else should use ==.
The interview trap comes from CPython's interning optimizations: integers from -5 to 256 are pre-allocated once at startup, so `a = 256; b = 256; a is b` is True, while `a = 257; b = 257` evaluated on separate lines in a REPL gives False because each line creates a new int object. Short strings that look like identifiers are also interned, so 'hello' is 'hello' is usually True, but 'hello world!' may not be. To make it worse, the compiler folds constants within a single compilation unit, so results differ between a REPL, a script, and a function body.
The practical lesson: identity of immutables is an implementation detail that varies by version and context, and any code depending on it is broken. PEP 8 codifies the one hard rule: comparisons to None must use `is`, never ==, both because it reads as intent and because a class can override __eq__ to return anything (SQLAlchemy column comparisons famously do, which is why `column == None` appears in queries but `is None` appears in logic). If you can explain interning, constant folding, and the SQLAlchemy exception, this question is fully closed.
a = 256
b = 256
print(a is b) # True: small ints (-5..256) are cached at startup
c = 1000
d = 1000
# In a REPL line-by-line: False. Inside one script/function: often True
# because the compiler folds constants per compilation unit.
print(c is d)
print(c == d) # True, always: value comparison
x = None
print(x is None) # correct: identity check against the singleton
s1 = 'user_id'
s2 = 'user_id'
print(s1 is s2) # True: identifier-like strings get interned
Q6Explain *args, **kwargs, and the / and * markers in function signatures.
BasicFunctions
Answer
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. On the calling side the same symbols unpack: f(*a_list, **a_dict). They are essential for writing decorators and wrappers that forward any signature.
The signature markers are the part many candidates miss. A bare * in the parameter list makes everything after it keyword-only: `def connect(host, *, timeout=5, retries=3)` forces callers to write timeout=10, which protects call sites from silent argument-order bugs when the signature evolves. A / makes everything before it positional-only: `def pow_mod(base, exp, /)` means callers cannot write base=2, which frees the parameter names from being public API and matches how many C-implemented builtins behave (you cannot call len(obj=x)).
Real libraries use both: much of the stdlib and NumPy adopted positional-only markers, and keyword-only arguments are standard in well-designed APIs like dataclasses.field(*, default_factory=...). Ordering rule: positional-only, /, normal, *args or bare *, keyword-only, **kwargs. Two gotchas worth volunteering: **kwargs preserves insertion order (guaranteed since 3.6, PEP 468), and forwarding with f(*args, **kwargs) loses type information unless you type the wrapper with ParamSpec (typing, 3.10+), which is exactly how decorator-heavy codebases keep IDE autocomplete working. Interviewers often finish by asking you to read a gnarly signature aloud; practice on `def f(a, b=1, /, c=2, *, d, **rest)` until the grouping is automatic.
def connect(host, port, /, scheme='https', *, timeout=5, **extra):
# host, port: positional-only; scheme: either; timeout: keyword-only
return f'{scheme}://{host}:{port} timeout={timeout} extra={extra}'
print(connect('api.example.test', 443, timeout=30, trace=True))
# connect(host='x', port=1) TypeError: positional-only
# connect('x', 1, 'http', 30) TypeError: timeout is keyword-only
def log_call(fn):
def wrapper(*args, **kwargs): # forwards ANY signature
print(f'calling {fn.__name__} args={args} kwargs={kwargs}')
return fn(*args, **kwargs)
return wrapper
params = {'scheme': 'http', 'timeout': 1}
print(connect('localhost', 8000, **params)) # dict unpacking at call site
Q7What is the difference between copy.copy() and copy.deepcopy(), and when does a shallow copy bite you?
BasicMemory
Answer
copy.copy() (and idioms like list(x), x[:], dict(x)) creates a new outer container whose elements are references to the same inner objects. copy.deepcopy() recursively copies the whole object graph, so nothing is shared, and it even handles cycles by memoizing already-copied objects. The bite happens with nested structures: copying a list of dicts shallowly gives you a new list, but mutating one of the dicts through either name changes both, because the dicts themselves were never copied. The most common real-world versions of this bug are copying a config dict before per-request mutation (nested sections remain shared across requests) and the multiplication trap: grid = [[0] * 3] * 3 creates three references to the SAME inner list, so grid[0][0] = 1 changes the first column of every row.
The correct construction is a comprehension: [[0] * 3 for _ in range(3)]. Deepcopy is not free: it walks the entire graph, is slow on large structures, and fails on objects holding unpicklable resources like open sockets or locks unless the class defines __deepcopy__. So in production the pattern is usually to avoid needing deepcopy at all: build new structures explicitly, use immutable types (tuples, frozen dataclasses) for shared data, or serialize and rebuild through a schema layer like pydantic.
Classes can customize both behaviors with __copy__ and __deepcopy__. If asked how you would copy a dataclass instance, dataclasses.replace(obj, field=new_value) is the idiomatic shallow-copy-with-changes answer.
import copy
config = {'db': {'host': 'localhost', 'pool': 5}, 'debug': False}
request_cfg = copy.copy(config)
request_cfg['db']['pool'] = 50 # mutates the SHARED inner dict
print(config['db']['pool']) # 50 <- original corrupted
safe_cfg = copy.deepcopy(config)
safe_cfg['db']['pool'] = 100
print(config['db']['pool']) # still 50
# The multiplication trap:
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] all rows shared!
ok = [[0] * 3 for _ in range(3)]
ok[0][0] = 1
print(ok) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
Key Points
- Shallow copy: new container, shared children; deepcopy: full graph copy
- [[0]*3]*3 shares one inner list across rows; use a comprehension
- deepcopy is slow and fails on sockets/locks without __deepcopy__
- dataclasses.replace() is the idiomatic copy-with-changes
Q8Why is `def f(items=[])` a bug, and what is the correct pattern?
BasicFunctions
Answer
Default argument values are evaluated exactly once, at function definition time, not at every call. The default object is stored on the function itself (visible in f.__defaults__), so a mutable default like [] or {} is shared across every call that does not pass the argument. The first call appends to the list; the second call sees the leftovers from the first.
This produces bugs that pass code review and unit tests (which often call the function once) and then corrupt state in production where the process lives for days. The correct pattern is to default to None and create the fresh object inside the body: `def f(items=None): items = [] if items is None else items`. You can also write it as a plain `if items is None: items = []`; both are fine, what matters is that construction happens per call.
This behavior is not a design accident: evaluating defaults once is what makes them cheap, and it is occasionally used deliberately, for example caching an expensive computed default or binding the current value of a loop variable when defining lambdas in a loop (`lambda x, n=n: x * n`). Interviewers often extend the question in exactly that direction, so know both the bug and the legitimate uses. Also worth mentioning: ruff flags this as B006 (mutable-argument-default) once the flake8-bugbear rules are enabled, so in a modern toolchain this bug should never reach main.
def broken(item, bucket=[]):
bucket.append(item)
return bucket
print(broken(1)) # [1]
print(broken(2)) # [1, 2] <- surprise: same list object reused
print(broken.__defaults__) # ([1, 2],) the default lives on the function
def fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(fixed(1)) # [1]
print(fixed(2)) # [2]
# Deliberate use: freeze loop variable at definition time
multipliers = [lambda x, n=n: x * n for n in range(3)]
print([m(10) for m in multipliers]) # [0, 10, 20]
Key Points
- Defaults evaluate once at def time and live on func.__defaults__
- Mutable defaults are shared across calls: classic production bug
- Fix: default to None, construct inside the body
- ruff rule B006 (enable the bugbear rules) catches this automatically
Q9Is Python compiled or interpreted? What actually happens when you run `python app.py`?
BasicBasics
Answer
Both, in stages. CPython does not interpret your source text line by line. It first compiles the .py file into bytecode, a compact instruction set for CPython's stack-based virtual machine, and then the eval loop executes those instructions.
For imported modules (not the entry script), the compiled bytecode is cached on disk as a .pyc file inside a __pycache__ directory, tagged with the interpreter version, for example utils.cpython-313.pyc. On the next run Python compares the source file's metadata with the cached file and skips recompilation if nothing changed, which is why large projects start faster the second time. You can inspect bytecode yourself with the dis module, and interviewers like candidates who have actually done this because it explains real behavior: why function-local variable access (LOAD_FAST) is faster than global access (LOAD_GLOBAL), and why CPython 3.11+ got large speedups from the specializing adaptive interpreter, which rewrites generic bytecode into specialized versions at runtime based on observed types.
Two production-relevant notes: first, bytecode is not machine code, so this is unrelated to the JIT compiler that arrived as an experimental build option in Python 3.13; second, .pyc files are an implementation detail, never commit them, and a stale __pycache__ after switching branches can occasionally cause confusing ImportErrors that a simple `find . -name __pycache__ -exec rm -rf {} +` resolves. Mentioning the compile-then-execute pipeline, the version-tagged cache, and dis is usually enough to close this question strongly.
import dis
def greet(name):
message = f'hello {name}'
return message.upper()
dis.dis(greet)
# Output (3.14; exact opcode names vary by version) shows stack-machine instructions like:
# LOAD_CONST 'hello '
# LOAD_FAST name
# FORMAT_SIMPLE
# BUILD_STRING 2
# STORE_FAST message
# LOAD_FAST message
# LOAD_ATTR upper
# CALL 0
# RETURN_VALUE
Key Points
- Source is compiled to bytecode, then executed by the CPython VM
- Imported modules cache bytecode in __pycache__/*.pyc, version-tagged
- dis.dis() shows the instructions; LOAD_FAST vs LOAD_GLOBAL explains locals being faster
- 3.11+ specializing adaptive interpreter is why upgrades alone made code faster
Q10Is Python dynamically typed or strongly typed, and what is PEP 8?
BasicBasics
Answer
Both. Python is dynamically typed because types are attached to objects, not to variable names, and are checked at runtime: the same name can refer to an int and later to a str. It is also strongly typed because the interpreter does not silently coerce unrelated types: '2' + 2 raises TypeError instead of producing '22' or 4. The only implicit conversions are numeric widening (int to float to complex) and truthiness in boolean contexts.
Type hints (PEP 484 onward) do not change this. They are annotations that tools such as mypy and pyright check before you run the code, while CPython itself ignores them at runtime unless a library such as pydantic or FastAPI reads them deliberately.
PEP 8 is the official style guide for Python code: four-space indentation, snake_case for functions and variables, CapWords for classes, UPPER_CASE for constants, a 79-character line limit (teams commonly relax it to 88 or 100), import grouping, and whitespace rules. In 2026 almost nobody enforces it by hand; ruff (or black plus flake8) formats and lints on save and in CI. The interview point is that consistent style is a team contract, not a personal preference.
x = 10
x = 'ten' # dynamic: the name is rebound to a different type
print(type(x).__name__)
try:
'2' + 2 # strong: no silent coercion
except TypeError as e:
print(e)
print(1 + 2.5) # numeric widening is the exception: 3.5
def total(prices: list[float]) -> float: # hints: for tools, ignored by CPython
return sum(prices)
print(total(['a', 'b']) if False else total([1.5, 2.5]))
print(total.__annotations__)
Key Points
- Dynamic: type lives on the object, checked at runtime, names can be rebound
- Strong: '2' + 2 is a TypeError, no implicit string-number coercion
- Type hints are for mypy/pyright and frameworks; CPython ignores them
- PEP 8: 4 spaces, snake_case, CapWords, line length; enforce with ruff or black
Q11List vs tuple vs set vs dict: when do you use each, and what is the difference between append() and extend()?
BasicData Types
Answer
Choose by the operation you need most. A list is an ordered, mutable sequence with duplicates, indexed by position, O(1) append and index, O(n) membership. A tuple is the immutable version, used for fixed records and as dict keys. A set is an unordered collection of unique hashable items with O(1) average membership, add and remove, and set algebra (union, intersection, difference). A dict maps unique hashable keys to values with O(1) average lookup and keeps insertion order since Python 3.7.
The rule of thumb interviewers want: list when order and duplicates matter, set when you only ask 'is it there?' or need de-duplication, dict when you look things up by a key, tuple when the data must not change or needs to be a key.
append() and extend() are the classic list follow-up. append(x) adds x as a single element, so appending a list nests it. extend(iterable) iterates its argument and adds each item, flattening one level. The same distinction appears with += (which extends) and insert(i, x) (which appends at a position). Mixing them up is one of the most common bugs in fresher coding rounds.
a = [1, 2]
a.append([3, 4]) # nests
print(a) # [1, 2, [3, 4]]
b = [1, 2]
b.extend([3, 4]) # flattens one level
print(b) # [1, 2, 3, 4]
c = [1, 2]
c += 'ab' # += calls extend: iterates the string!
print(c) # [1, 2, 'a', 'b']
# Membership cost: list O(n), set O(1) average
import timeit
big_list = list(range(100_000))
big_set = set(big_list)
print(round(timeit.timeit(lambda: 99_999 in big_list, number=200), 4))
print(round(timeit.timeit(lambda: 99_999 in big_set, number=200), 4))
# Set algebra reads like maths
applied = {'alice', 'bob', 'chen'}
shortlisted = {'bob', 'chen', 'dev'}
print(applied & shortlisted, applied - shortlisted)
Key Points
- list: ordered, duplicates, O(1) index/append, O(n) membership
- tuple: immutable list, hashable, fixed records and dict keys
- set: unique, unordered, O(1) membership, set algebra; frozenset is the hashable version
- dict: key to value, O(1) lookup, insertion-ordered since 3.7
- append adds one element (nests lists); extend iterates and adds each item
Q12What is a dictionary comprehension, and how do you merge two dictionaries?
BasicData Types
Answer
A dictionary comprehension builds a dict in one expression: {key: value for item in iterable if condition}. It reads like a for loop, runs faster than appending in a loop, and is the idiomatic way to invert a mapping, filter a dict, or build a lookup table from a list of records. Set comprehensions use the same syntax without the colon. If keys repeat, the last one wins, which is the gotcha interviewers test with an inversion of a dict that has duplicate values.
Merging: in Python 3.9 and later use the | operator, {**a, **b} works from 3.5, and a.update(b) mutates a in place. In every case the right-hand dict wins on duplicate keys. The |= operator updates in place. For merging many dicts, collections.ChainMap gives a view that searches each mapping in order without copying, which is useful for layered configuration (defaults, file, environment, command line).
Mention dict.get(key, default) and setdefault, and collections.defaultdict for grouping, since 'merge two dicts' is often followed by 'merge and sum the values', which Counter does in one line: Counter(a) + Counter(b).
from collections import ChainMap, Counter
squares = {n: n * n for n in range(5)}
print(squares) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
evens = {n: sq for n, sq in squares.items() if n % 2 == 0}
print(evens) # {0: 0, 2: 4, 4: 16}
prices = {'pen': 10, 'book': 150, 'bag': 150}
by_price = {v: k for k, v in prices.items()} # duplicate values: last wins
print(by_price) # {10: 'pen', 150: 'bag'}
a, b = {'x': 1, 'y': 2}, {'y': 20, 'z': 30}
print(a | b) # {'x': 1, 'y': 20, 'z': 30}
print({**a, **b})
merged = dict(a); merged.update(b)
print(merged == a | b) # True
config = ChainMap({'debug': True}, {'debug': False, 'port': 8000})
print(config['debug'], config['port']) # True 8000: first mapping wins
print(Counter(a) + Counter(b)) # sums values: {'z': 30, 'y': 22, 'x': 1}
Key Points
- {k: v for ... if ...}; last duplicate key wins; set comprehension drops the colon
- Merge with a | b (3.9+), {**a, **b}, or a.update(b); right side wins
- ChainMap layers mappings without copying; Counter(a) + Counter(b) sums values
Q13How does a Python dict work internally, and what guarantees does it make about ordering?
BasicData Types
Answer
A dict is an open-addressing hash table. Looking up d[key] hashes the key with hash(), maps the hash to a slot in a sparse index table, and resolves collisions by probing. Average lookup, insert, and delete are O(1).
Since CPython 3.6 (guaranteed by the language spec from 3.7), dicts preserve insertion order, because the implementation was rewritten as a compact layout: a dense array of entries in insertion order plus a small sparse index pointing into it, which also cut memory use significantly. That means collections.OrderedDict is now only needed for its extras like move_to_end() and order-sensitive equality. Keys must be hashable: they need a __hash__ that never changes and a consistent __eq__, which is why lists and dicts cannot be keys but tuples of immutables can.
A subtle rule pair interviewers probe: objects that compare equal must have equal hashes, and since hash(1) == hash(1.0) == hash(True), the keys 1, 1.0, and True collide into one entry (the first-inserted key object wins, the latest value wins). Defining __eq__ on a class without __hash__ sets the class unhashable. Production notes: iterating a dict while inserting or deleting raises RuntimeError (dictionary changed size during iteration), so collect keys first with list(d) or build a new dict; dict.setdefault and collections.defaultdict remove the common get-or-create boilerplate; and the merge operators d1 | d2 and d1 |= d2 (3.9+) are the modern way to combine dicts, with right-hand values winning on key conflict.
d = {}
d[1] = 'int'
d[1.0] = 'float' # hash(1) == hash(1.0): same slot
d[True] = 'bool' # hash(True) == hash(1): same slot again
print(d) # {1: 'bool'} one entry, first key kept, last value wins
from collections import defaultdict
words = ['pay', 'upi', 'pay', 'card']
counts = defaultdict(int)
for w in words:
counts[w] += 1
print(dict(counts)) # {'pay': 2, 'upi': 1, 'card': 1}
defaults = {'retries': 3, 'timeout': 5}
overrides = {'timeout': 30}
print(defaults | overrides) # {'retries': 3, 'timeout': 30} (3.9+)
# RuntimeError: dictionary changed size during iteration
# for k in d: del d[k] <- wrong
for k in list(d):
del d[k] # correct: snapshot the keys first
Q14What are sets and frozensets, and when do they beat lists in real code?
BasicData Types
Answer
A set is an unordered collection of unique, hashable elements backed by a hash table, so membership tests, adds, and removals are O(1) on average versus O(n) for a list. That single fact drives the main use cases: deduplication (set(emails)), fast membership (`if user_id in allowed_ids` where allowed_ids is a set, not a list), and set algebra: | union, & intersection, - difference, ^ symmetric difference, plus operator forms of issubset (<=) and issuperset (>=). Set algebra turns nested loops into one-liners: users who paid but never activated is paid_ids - active_ids; that is the difference between an O(n*m) double loop and O(n+m).
Two constraints follow from the hash-table design: elements must be hashable (no lists or dicts inside a set; use tuples), and iteration order is arbitrary and can differ between runs, partly because str hashes are randomized per process for DoS resistance (PYTHONHASHSEED controls this), so any test asserting on set iteration order is flaky by construction; sort first. frozenset is the immutable, hashable variant, usable as a dict key or as an element of another set, and is the right type for constants like ALLOWED_METHODS = frozenset({'GET', 'POST'}). Syntax gotchas interviewers check: {} creates an empty dict, not an empty set (use set()), and {1, 2} is a set literal. Practical judgment to volunteer: below roughly a few dozen elements the constant factors mean a tuple scan can compete, and if you need order plus dedup, dict.fromkeys(items) preserves insertion order while removing duplicates, the standard order-preserving dedup idiom since 3.7.
paid = {'u1', 'u2', 'u3', 'u4'}
active = {'u2', 'u4', 'u5'}
print(paid - active) # {'u1', 'u3'} paid but never activated
print(paid & active) # {'u2', 'u4'} both
print(paid | active) # union
print({'u2'} <= active) # True: subset test
# O(1) membership vs O(n):
allowed = set(load_allowed_ids()) # build once
if request_user in allowed: # constant-time check per request
pass
# Order-preserving dedup (sets do NOT preserve order):
clicks = ['home', 'jobs', 'home', 'apply', 'jobs']
print(list(dict.fromkeys(clicks))) # ['home', 'jobs', 'apply']
ALLOWED = frozenset({'GET', 'POST'}) # immutable, hashable constant
cache = {ALLOWED: 'route-1'} # usable as dict key
Q15How does slicing work in Python, including negative indices, steps, and slice assignment?
BasicData Types
Answer
seq[start:stop:step] returns a new sequence from start (inclusive) to stop (exclusive), stepping by step. All three parts are optional; negatives count from the end, so seq[-3:] is the last three elements and seq[::-1] is the canonical reversal idiom. Out-of-range slice bounds never raise (unlike indexing): 'abc'[10:20] is just '', which makes slicing safe for truncation like text[:280].
The half-open convention means seq[:k] + seq[k:] always reconstructs the sequence, which is why it was chosen. Slicing a list produces a shallow copy, so old_list[:] or list(old_list) copies the container but shares elements, connecting this topic to the copy question. Slice assignment mutates lists in place and can change length: lst[1:3] = [10, 20, 30] replaces two elements with three, lst[:] = new_values replaces the contents while preserving object identity (important when other code holds references to the same list), and del lst[::2] deletes every other element.
Under the hood, seq[1:5] calls __getitem__ with a slice object, slice(1, 5, None); you can create and reuse slice objects, LAST_WEEK = slice(-7, None), which reads well in data code, and slice.indices(len) normalizes negative bounds for you when implementing __getitem__ on your own class. Two gotchas close the answer: a step of zero raises ValueError, and strings/tuples being immutable support slice reads but never slice assignment. NumPy deliberately breaks the copy rule, array slices are views, so mutating a NumPy slice mutates the original: a favorite follow-up for data roles.
items = list(range(10)) # [0..9]
print(items[2:8:2]) # [2, 4, 6]
print(items[-3:]) # [7, 8, 9]
print(items[::-1]) # reversed copy
print('hello'[10:99]) # '' no IndexError on slice bounds
# Slice assignment mutates in place, can resize:
items[1:3] = [100, 200, 300]
print(len(items)) # 11
shared = items
items[:] = [1, 2, 3] # replace contents, SAME object
print(shared) # [1, 2, 3] other references see it
LAST_TWO = slice(-2, None) # named, reusable slice object
print('goodspace'[LAST_TWO]) # ce
Q16What can f-strings do beyond simple interpolation, and what changed for them in Python 3.12?
BasicStrings
Answer
f-strings evaluate arbitrary expressions inside {} at runtime and support the full format-spec mini-language after a colon: {value:.2f} for two decimals, {n:,} for thousands separators, {x:>10} for right-alignment in ten characters, {ts:%Y-%m-%d} for datetime formatting, and {n:08b} for zero-padded binary. Two modifiers matter daily: the = debug specifier, {user_id=}, prints both the expression text and its value, which is the fastest print-debugging idiom in the language; and the conversion flags !r, !s, !a force repr(), str(), or ascii(), with {obj!r} being the right choice in log messages so strings show their quotes and None is visible. Format specs can themselves be nested expressions: {value:{width}.{precision}f}.
Python 3.12 (PEP 701) formalized the f-string grammar in the main parser, which lifted old restrictions: you can now reuse the same quote type inside the expression, f"{d["key"]}" works, expressions can span multiple lines with comments, and backslashes are allowed inside the expression part. Error messages also improved to point precisely inside the f-string. Two cautions worth stating in an interview: never build SQL with f-strings (use parameterized queries; f-string SQL is the injection cliche), and in logging calls prefer logger.info('user %s', uid) over f-strings so the formatting cost is skipped when the level is disabled and log aggregation can group by template. For 3.14, PEP 750 added template strings (t-strings), which produce a structured Template object instead of a str, designed exactly for safe SQL/HTML building, worth mentioning as the forward direction.
from datetime import datetime
amount = 1234567.891
user_id = 42
name = None
print(f'{amount:,.2f}') # 1,234,567.89
print(f'{user_id=}') # user_id=42 <- debug specifier
print(f'{name!r}') # None (repr makes None visible in logs)
print(f'{amount:>15.1f}|') # 1234567.9|
print(f'{datetime.now():%d %b %Y}') # 11 Aug 2026
width, precision = 12, 3
print(f'{amount:{width}.{precision}f}') # nested format spec
d = {'key': 'value'}
print(f"{d["key"]}") # legal since 3.12 (PEP 701): same quotes reused
Key Points
- Format spec mini-language: alignment, padding, separators, datetime
- {expr=} debug specifier and !r conversion are daily drivers
- 3.12 PEP 701: nested same quotes, multi-line expressions, backslashes
- Never f-string SQL; use parameters. Prefer %s lazy formatting in logging
Q17What is the difference between str and bytes, and where do UnicodeDecodeError and UnicodeEncodeError come from?
BasicStrings
Answer
str is an immutable sequence of Unicode code points: text with no inherent byte representation. bytes is an immutable sequence of integers 0-255: raw binary. They convert only through an explicit encoding: text.encode('utf-8') produces bytes; data.decode('utf-8') produces text. Mixing them fails loudly, 'a' + b'b' raises TypeError, which was the entire point of the Python 3 redesign after Python 2's silent mojibake.
UnicodeDecodeError means you tried to interpret bytes with the wrong codec, classically reading a Windows-exported CSV (cp1252) as UTF-8: 'utf-8' codec can't decode byte 0x92. UnicodeEncodeError is the reverse, such as encoding Hindi text to 'ascii'. The production rules: know your boundaries; decode as early as possible, work in str everywhere inside the program, and encode as late as possible at the output boundary (the 'unicode sandwich'). open() in text mode decodes for you and its default encoding was historically platform-dependent, the source of 'works on my Mac, breaks on Windows' bugs; always pass encoding='utf-8' explicitly.
Python 3.15 made UTF-8 the default encoding (PEP 686); on older versions opt in with the -X utf8 flag or PYTHONUTF8=1, and keep passing encoding explicitly in code that must run on both. The error-handler parameter matters for dirty data: errors='replace' substitutes the replacement character, 'ignore' drops bytes (dangerous, silent data loss), and 'surrogateescape' round-trips undecodable bytes, which is how the OS interfaces smuggle arbitrary filenames through str. Related types worth one line each: bytearray is the mutable twin of bytes for building binary buffers, and memoryview slices large binary data without copying. For data roles, add that len() on str counts code points, not user-perceived characters or bytes: len('เคจเคฎเคธเฅเคคเฅ') is 6 code points and its UTF-8 encoding is 18 bytes.
text = 'เคจเคฎเคธเฅเคคเฅ' # str: code points
raw = text.encode('utf-8') # bytes
print(len(text), len(raw)) # 6 18
print(raw.decode('utf-8')) # เคจเคฎเคธเฅเคคเฅ
try:
raw.decode('ascii')
except UnicodeDecodeError as e:
print(e) # 'ascii' codec can't decode byte 0xe0 ...
# Windows-exported CSV read safely:
with open('report.csv', encoding='cp1252', errors='replace') as f:
pass
# ALWAYS pin encoding at boundaries:
with open('out.txt', 'w', encoding='utf-8') as f:
f.write(text)
buf = bytearray()
buf += b'\x89PNG' # mutable byte buffer
print(bytes(buf))
Q18sorted() versus list.sort(): how do key functions, stability, and reverse sorting actually work?
BasicData Types
Answer
list.sort() sorts in place and returns None, a deliberate design so `result = mylist.sort()` fails obviously instead of silently aliasing; sorted(iterable) accepts ANY iterable and returns a new list. Both use the same algorithm (Timsort, a hybrid merge/insertion sort exploiting existing runs, O(n log n) worst case, O(n) on already-sorted data) and both take two keyword-only parameters: key and reverse. key is called once per element to produce the comparison value, so key=str.lower gives case-insensitive sorting, key=len sorts by length, and operator.itemgetter('salary') or attrgetter('joined_at') sort dicts and objects without lambdas. Multi-level sorting has two idioms: return a tuple from key, key=lambda r: (r['dept'], -r['salary']), or exploit stability, sort by the secondary key first, then by the primary; equal elements keep their relative order, which is the definition of a stable sort and the single most-probed property here.
The negation trick only works for numbers; for mixed directions on strings, chained stable sorts are the answer. Python 3 removed the cmp parameter; if you genuinely have a comparator function (say, a version-comparison rule), wrap it with functools.cmp_to_key. Related tools worth naming: min/max take the same key parameter; heapq.nlargest(5, rows, key=...) beats a full sort when you need only the top few of a large dataset; and bisect.insort maintains a sorted list incrementally. One gotcha closes it: Python 3 refuses to compare unlike types, so sorting [3, '1', 2] raises TypeError: '<' not supported between instances of 'str' and 'int', where Python 2 silently produced nonsense; clean your data or provide a normalizing key.
from operator import itemgetter
from functools import cmp_to_key
employees = [
{'name': 'asha', 'dept': 'eng', 'salary': 32},
{'name': 'ravi', 'dept': 'sales', 'salary': 18},
{'name': 'meera', 'dept': 'eng', 'salary': 41},
]
# Multi-key: dept ascending, salary descending
by_dept = sorted(employees, key=lambda e: (e['dept'], -e['salary']))
print([e['name'] for e in by_dept]) # ['meera', 'asha', 'ravi']
# Same result via stability: secondary sort first, then primary
employees.sort(key=itemgetter('salary'), reverse=True)
employees.sort(key=itemgetter('dept')) # stable: keeps salary order
# Comparator survivors from legacy code:
def compare_versions(a, b):
pa, pb = [int(x) for x in a.split('.')], [int(x) for x in b.split('.')]
return (pa > pb) - (pa < pb)
print(sorted(['1.10', '1.2', '1.9'], key=cmp_to_key(compare_versions)))
import heapq
print(heapq.nlargest(2, employees, key=itemgetter('salary')))
Q19List comprehension versus generator expression: when does the difference actually matter?
BasicGenerators
Answer
A list comprehension `[x*2 for x in data]` builds the entire list in memory immediately. A generator expression `(x*2 for x in data)` builds a lazy iterator that produces values one at a time as something consumes it. Three practical consequences decide which to use.
Memory: summing a computation over ten million rows with sum(x*2 for x in rows) holds one element at a time, while the list version materializes all ten million first; for large ETL jobs this is the difference between a working pipeline and an OOM-killed pod. Single consumption: a generator is exhausted after one pass; iterating it again silently yields nothing, which is a real bug class when a generator is passed to a function that iterates twice (say, computing min and max). If you need multiple passes or len(), you need a list.
Laziness interacts with time: a generator captures the iterable but evaluates lazily, so if the underlying data mutates between creation and consumption, you see the mutated data. There are also dict and set comprehensions ({k: v for ...}, {x for ...}) and since a generator expression as a sole function argument needs no extra parentheses, sum(x*x for x in nums) is idiomatic. One more detail worth volunteering: comprehensions run in their own scope, so the loop variable does not leak (unlike Python 2), and in Python 3.12 comprehension inlining made them measurably faster. The interviewer's real question is whether you reason about memory and consumption semantics, so anchor your answer in a concrete pipeline scenario.
import sys
nums = range(1_000_000)
as_list = [n * n for n in nums]
as_gen = (n * n for n in nums)
print(sys.getsizeof(as_list)) # ~8 MB of pointers
print(sys.getsizeof(as_gen)) # ~200 bytes regardless of size
print(sum(as_gen)) # consumes the generator
print(sum(as_gen)) # 0 <- already exhausted, silent bug
# Generator is lazy over live data:
data = [1, 2, 3]
gen = (x for x in data)
data.append(4)
print(list(gen)) # [1, 2, 3, 4] sees the mutation
Key Points
- List comp materializes everything; genexp is lazy and O(1) memory
- Generators are single-use; second iteration yields nothing silently
- Genexp as a sole argument needs no extra parens: sum(x for x in xs)
- Comprehensions have their own scope; loop variable does not leak
Q20What is the iterator protocol? Explain __iter__, __next__, and StopIteration.
BasicGenerators
Answer
An iterable is any object whose __iter__ returns an iterator (lists, dicts, strings, files, generators). An iterator is an object with __next__, which returns the next value or raises StopIteration when exhausted, and its own __iter__ returning self, so iterators are also iterable. A for loop is sugar for exactly this: it calls iter(obj) once, then next() repeatedly, and catches StopIteration to end cleanly.
The distinction that trips people: a list is an iterable but not an iterator; iter(list) gives you a fresh listiterator each time, which is why you can loop over a list twice. A generator IS an iterator, so it exhausts after one pass. Implementing the protocol by hand is rare because writing __iter__ as a generator method (def __iter__(self): yield from self._items) gives you a correct, fresh iterator per loop for free. iter() has a lesser-known two-argument form, iter(callable, sentinel), which calls the callable until it returns the sentinel: iter(functools.partial(sock.recv, 4096), b'') is a classic way to read a socket until EOF. next() takes a default, next(it, None), avoiding the try/except for 'give me the first item if any'. Two version-specific notes interviewers respect: PEP 479 (since 3.7) means a StopIteration raised inside a generator body becomes RuntimeError rather than silently terminating the generator, closing an old bug class; and files are iterators over lines, so `for line in f` streams a multi-gigabyte log in constant memory, while f.readlines() loads all of it, a distinction that matters the day you process real production logs.
class Countdown:
def __init__(self, n):
self.n = n
def __iter__(self): # iterable: returns fresh iterator
n = self.n
while n > 0:
yield n
n -= 1
c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # [3, 2, 1] again: __iter__ gives a new generator
# What `for` really does:
it = iter([10, 20])
print(next(it)) # 10
print(next(it)) # 20
print(next(it, 'done')) # 'done' default instead of StopIteration
# Two-arg iter(): pull chunks until sentinel
import io
stream = io.BytesIO(b'abcdef')
for chunk in iter(lambda: stream.read(2), b''):
print(chunk) # b'ab', b'cd', b'ef'
Q21What do enumerate() and zip() do, and what does zip(strict=True) fix?
BasicBasics
Answer
enumerate(iterable, start=0) yields (index, item) pairs lazily, replacing the C-style range(len(seq)) loop; the start parameter handles 1-based output like ranked lists directly. zip(*iterables) yields tuples pairing elements positionally, stopping silently at the SHORTEST input. That silent truncation is a real bug source: zipping a list of user ids against a list of scores that lost a row upstream produces clean-looking, misaligned output with no error. Python 3.10 added zip(a, b, strict=True), which raises ValueError('zip() argument 2 is shorter than argument 1') on length mismatch; in data pipelines strict=True should be your default unless truncation is intended, and ruff's B905 rule flags zip calls without an explicit strict argument for exactly this reason.
When you want the longest input padded instead, itertools.zip_longest(a, b, fillvalue=None) does that. Useful compositions: dict(zip(keys, values)) builds a dict from parallel lists; zip(*matrix) transposes rows and columns because the unpacking passes each row as a separate argument; and `for i, (name, score) in enumerate(zip(names, scores), start=1)` combines both with tuple unpacking in the for target. Everything here is lazy: zip and enumerate return iterators, so they compose with generators in constant memory. One conceptual follow-up interviewers use: zip(*zipped) un-zips, recovering the original sequences, and being able to explain why (each tuple becomes an argument, and zip pairs first elements, second elements, and so on) confirms you actually model the mechanics rather than memorizing the trick.
names = ['asha', 'ravi', 'meera']
scores = [91, 84] # one row lost upstream
print(list(zip(names, scores))) # [('asha', 91), ('ravi', 84)] silent loss!
try:
list(zip(names, scores, strict=True)) # 3.10+
except ValueError as e:
print(e) # zip() argument 2 is shorter than argument 1
from itertools import zip_longest
print(list(zip_longest(names, scores, fillvalue=0)))
for rank, (name, score) in enumerate(zip(names, scores), start=1):
print(f'{rank}. {name}: {score}')
matrix = [[1, 2, 3], [4, 5, 6]]
print(list(zip(*matrix))) # [(1, 4), (2, 5), (3, 6)] transpose
Q22How does truthiness work in Python, and when is `if not x` a bug compared to `if x is None`?
BasicBasics
Answer
Every object has a truth value. bool(x) checks, in order: a __bool__ method if defined; else __len__ (nonzero length is truthy); else the object is truthy by default. The falsy built-ins are None, False, 0, 0.0, 0j, Decimal('0'), empty str/bytes/list/tuple/dict/set/range. Everything else, including every plain user object, is truthy.
This powers clean idioms, `if not errors:` reads naturally for an empty list, but it also creates a well-defined bug class: conflating 'absent' with 'falsy'. If a function returns None for 'no record' but might legitimately return 0, '' or [], then `if not result:` treats a real zero-valued answer as missing. Concrete examples: a count of 0 from the database, an empty string a user deliberately saved, an empty list that means 'no filters', or a NumPy array (where bool() on a multi-element array raises 'The truth value of an array is ambiguous', by design).
The rule: when None is your sentinel, test identity explicitly, `if result is None:`, and reserve truthiness tests for cases where all falsy values genuinely mean the same thing. Related behaviors interviewers attach here: `and`/`or` return operands, not booleans, so `name = provided or 'default'` works but inherits the same 0/'' trap, which is why careful code uses `provided if provided is not None else 'default'`; short-circuiting means the right operand may never evaluate (useful for `obj and obj.field`); and defining __bool__ on your own class lets domain objects participate, for example an empty Basket being falsy, but if you define __len__ you get sensible truthiness for free. Chained comparisons (0 < x < 10) evaluate x once and are the readable range test.
def get_discount(user):
return 0 # a REAL answer: zero discount
discount = get_discount('u1')
if not discount:
print('missing?') # WRONG: 0 is a valid value, not absence
if discount is None:
print('missing') # correct sentinel check: does not fire
# or-default inherits the same trap:
page_size = 0
limit = page_size or 50
print(limit) # 50 <- silently discarded a legitimate 0
class Basket:
def __init__(self):
self.items = []
def __len__(self):
return len(self.items)
b = Basket()
print(bool(b)) # False via __len__
print(3 < len('abcd') < 10) # chained comparison, single evaluation
Key Points
- bool(x): __bool__ first, then __len__, else truthy
- Falsy: None, False, all zeros, all empty collections
- `if not x` conflates 0/''/[] with None; use `is None` for sentinels
- and/or return operands and short-circuit: `x or default` has the 0 trap
Q23What are lambda functions, what can't they contain, and when should you use map/filter versus a comprehension?
BasicFunctions
Answer
A lambda is an anonymous function limited to a single expression: no statements, no assignments (except the walrus operator), no annotations, and it returns the expression's value implicitly. Its legitimate home is short, throwaway key functions: sorted(rows, key=lambda r: r['ctc']), max(jobs, key=lambda j: j.applicants), or callbacks in GUI/async code. PEP 8 explicitly says do not assign a lambda to a name, `f = lambda x: x + 1`, because def gives you a real __name__ for tracebacks and profilers at identical cost.
For attribute/item access, operator.itemgetter('ctc') and attrgetter('applicants') are faster and clearer than the equivalent lambdas. map(fn, iterable) and filter(pred, iterable) return lazy iterators applying fn or keeping truthy-pred elements. The honest 2026 guidance: comprehensions are preferred in idiomatic Python because they read left-to-right and fuse mapping with filtering, [x.strip() for x in lines if x], where the map/filter version needs a lambda and two nested calls. map still wins in two situations: when the function already exists (map(int, tokens) beats [int(t) for t in tokens] on both clarity and slightly on speed, since no Python-level frame per element is created for the C-implemented int), and when passing multiple iterables, map(operator.mul, prices, quantities). functools.reduce completes the trio but Guido demoted it out of builtins for a reason; sum, max, any, all, and math.prod cover most real folds more readably. A useful closing observation for interviews: lambdas capture variables by reference like any closure, so the loop-variable late-binding trap applies to them fully, tying this question back to scoping.
from operator import itemgetter, mul
jobs = [
{'title': 'sde-2', 'ctc': 32},
{'title': 'data-eng', 'ctc': 28},
]
# Good lambda: inline key
top = max(jobs, key=lambda j: j['ctc'])
# Better for pure access: itemgetter
top = max(jobs, key=itemgetter('ctc'))
print(top['title'])
tokens = ['1', '2', '3']
print(list(map(int, tokens))) # existing function: map shines
print([int(t) for t in tokens]) # equivalent comprehension
prices, qty = [10, 20], [3, 2]
print(list(map(mul, prices, qty))) # [30, 40] multi-iterable map
# Comprehension fuses map+filter more readably than nested calls:
lines = [' apply ', '', ' hire ']
print([s.strip() for s in lines if s.strip()])
print(list(filter(None, map(str.strip, lines)))) # same, less readable
Q24Show the unpacking idioms: starred assignment, swapping, merging dicts, and unpacking in function calls.
BasicBasics
Answer
Iterable unpacking assigns elements of any iterable to multiple names: a, b = pair; and the classic swap a, b = b, a works because the right side builds a tuple before any assignment happens, no temp variable needed. Starred assignment (PEP 3132) captures 'the rest' as a list from either end or the middle: first, *middle, last = row; head, *tail = items. This is how you split a CSV row into fixed leading fields plus variable trailing ones without index arithmetic.
In function calls, * spreads an iterable into positional arguments and ** spreads a mapping into keyword arguments: plot(*coords), request(**options); combined with zip(*matrix) you get the transpose idiom. In literals (PEP 448), unpacking builds merged collections: [*a, *b], {*s1, *s2}, and {**defaults, **overrides}, where later keys win; since 3.9 the dict-specific operators d1 | d2 and d1 |= d2 do the same with clearer intent. Unpacking also appears in for-loop targets, `for name, (lat, lng) in cities.items():`, and in match/case sequence patterns.
Two rules interviewers verify: unpacking arity must match exactly or you get ValueError: too many values to unpack (expected 2), with starred targets absorbing the variance; and only one starred target is allowed per assignment. A last idiom worth showing: returning multiple values from a function is just tuple packing plus unpacking at the call site, ok, payload = parse(msg), and ignoring a value with _ is convention, not syntax (the name _ is really bound, and gettext users pick a different throwaway name). These forms appear constantly in code-reading rounds; fluency here signals day-to-day Python use more than almost anything else.
# Swap without temp: RHS tuple built first
a, b = 1, 2
a, b = b, a
row = ['u42', 'Asha', 'Pune', 'python', 'sql', 'aws']
uid, name, city, *skills = row
print(skills) # ['python', 'sql', 'aws']
first, *_, last = [10, 20, 30, 40]
print(first, last) # 10 40
defaults = {'retries': 3, 'timeout': 5}
overrides = {'timeout': 30}
merged = {**defaults, **overrides, 'trace': True}
print(merged) # timeout=30: later keys win
print(defaults | overrides) # 3.9+ operator form
def notify(user, *, channel, template):
return f'{user} via {channel}: {template}'
opts = {'channel': 'whatsapp', 'template': 'otp_v2'}
print(notify('u42', **opts))
matrix = [(1, 2), (3, 4)]
cols = list(zip(*matrix)) # transpose via call-site unpacking
Q25Explain Python's LEGB scoping rules and the difference between global and nonlocal.
BasicFunctions
Answer
Name lookup walks four scopes in order: Local (the current function), Enclosing (any outer function, for closures), Global (the module), Built-ins (print, len). The first match wins. Assignment is the twist: assigning to a name anywhere in a function makes that name local for the WHOLE function at compile time, which produces the classic UnboundLocalError: a function that reads counter then later does counter += 1 fails on the read, because the compiler already classified counter as local.
The fix depends on intent: `global counter` rebinds a module-level name; `nonlocal counter` rebinds a name in the nearest enclosing function, which is what closure-based accumulators need. Note that mutation is not assignment: appending to an enclosing list needs no declaration, only rebinding does. Two related gotchas belong in this answer.
First, late binding: closures capture variables, not values, so lambdas created in a loop all see the loop variable's final value; freeze it with a default argument (lambda x, n=n: ...) or functools.partial. Second, class bodies are a special scope that does NOT participate in closure lookup, which is why a comprehension inside a class body cannot see other class attributes directly (a favorite senior-round trick). Style guidance to volunteer: `global` at any meaningful scale is a design smell; module-level mutable state breaks testability and thread-safety, and the usual refactor is a class, an explicit parameter, or contextvars for request-scoped state in async services.
count = 0
def broken():
print(count) # UnboundLocalError: count became local
count = 1 # ...because of this assignment (compile-time)
def make_counter():
total = 0
def add(n):
nonlocal total # rebind enclosing name
total += n
return total
return add
counter = make_counter()
counter(5)
print(counter(3)) # 8 state lives in the closure cell
# Late binding: all three see n's FINAL value
fns = [lambda: n for n in range(3)]
print([f() for f in fns]) # [2, 2, 2]
fixed = [lambda n=n: n for n in range(3)]
print([f() for f in fixed]) # [0, 1, 2]
Key Points
- Lookup order: Local, Enclosing, Global, Built-ins
- Assignment anywhere in a function makes the name local: UnboundLocalError
- nonlocal for enclosing scope, global for module scope; mutation needs neither
- Closures capture variables not values: freeze with default args or partial
Q26Walk through try/except/else/finally, exception chaining with `raise ... from`, and EAFP versus LBYL.
BasicExceptions
Answer
The full statement has four clauses. try holds the risky code; except catches, and should name the narrowest exception class possible (except ValueError, not bare except, which also swallows KeyboardInterrupt and SystemExit; if you must catch broadly, catch Exception and log). else runs only when no exception occurred, and its point is precision: code in else is not protected by the except clauses, so a bug there cannot be silently misattributed to the operation you meant to guard. finally always runs, even on return or exception, and is for cleanup, though `with` blocks have replaced most manual finally usage. Chaining: inside an except block, `raise AppError('payment failed') from exc` sets __cause__ and prints 'The above exception was the direct cause of the following exception:', preserving the original traceback for debugging while presenting a domain-level error to callers. Raising without `from` inside except still auto-chains as __context__ ('During handling..., another exception occurred'), and `raise X from None` suppresses chaining when the original is noise.
EAFP ('easier to ask forgiveness than permission') means try the operation and handle failure: try: d[key] except KeyError. LBYL ('look before you leap') checks first: if key in d. Python idiom prefers EAFP because it avoids TOCTOU races (the check and the use are not atomic; a file can vanish between os.path.exists and open) and because exceptions are cheap when not raised in CPython. Since 3.11, fine-grained tracebacks underline the exact expression that failed, and add_note() lets you attach context like the offending record id to an in-flight exception, which is genuinely useful in batch pipelines.
class PaymentError(Exception):
pass
def charge(order):
try:
resp = gateway_call(order) # risky operation only
except TimeoutError as exc:
raise PaymentError(f'gateway timeout for {order.id}') from exc
except KeyError:
raise # re-raise unchanged, preserves original traceback
else:
record_success(resp) # runs only on success,
# NOT protected by excepts above
finally:
release_lock(order.id) # always runs
# EAFP over LBYL: atomic, no TOCTOU race
try:
with open('config.toml', 'rb') as f:
data = f.read()
except FileNotFoundError:
data = b''
Key Points
- Bare except swallows KeyboardInterrupt; catch narrow or Exception
- else = success-only code kept outside the guarded region
- raise X from exc sets __cause__; from None silences chaining
- EAFP beats LBYL for atomicity; 3.11+ tracebacks underline the failing expression
Q27What is the difference between an error and an exception in Python, and between a syntax error and a runtime exception?
BasicExceptions
Answer
In everyday Python usage, 'error' and 'exception' are mostly the same thing: every exception class derives from BaseException, and most built-in ones are named with the Error suffix (ValueError, TypeError, KeyError). The distinction interviewers want is between errors detected before the program runs and exceptions raised while it runs.
A SyntaxError (and its subclass IndentationError) is found when Python compiles the file to bytecode, so nothing executes; you cannot catch it from the same file. Runtime exceptions occur while executing, can be caught with try/except, and let the program recover: a ZeroDivisionError, a FileNotFoundError, a KeyError. Some exceptions signal conditions rather than failures: StopIteration ends a for loop, KeyboardInterrupt and SystemExit derive from BaseException rather than Exception so that a bare `except Exception` does not swallow them.
Follow-ups: the hierarchy (BaseException, Exception, then LookupError with KeyError and IndexError, ArithmeticError with ZeroDivisionError, OSError with FileNotFoundError), why catching Exception at the top of a service is acceptable but a bare except is not, and how a NameError differs from an AttributeError.
# Syntax error: detected at compile time, before any line runs
try:
compile('def broken(:\n pass', '<demo>', 'exec')
except SyntaxError as e:
print('SyntaxError:', e.msg)
# Runtime exceptions: raised while executing, recoverable
for expr in ['10 / 0', "{'a': 1}['b']", 'int("x")', 'undefined_name']:
try:
eval(expr)
except (ZeroDivisionError, KeyError, ValueError, NameError) as e:
print(f'{type(e).__name__}: {e}')
# Hierarchy: KeyError and IndexError are both LookupError
print(issubclass(KeyError, LookupError), issubclass(IndexError, LookupError))
print(issubclass(KeyboardInterrupt, Exception)) # False: lives under BaseException
Key Points
- SyntaxError is found at compile time; nothing runs and you cannot catch it in the same file
- Runtime exceptions are catchable with try/except and allow recovery
- Exception vs BaseException: KeyboardInterrupt and SystemExit sit outside Exception
- Know the hierarchy: LookupError, ArithmeticError, OSError families
Q28How do context managers work, and how do you write one with __enter__/__exit__ and with contextlib?
BasicBasics
Answer
The with statement guarantees setup/teardown pairing: `with open(path) as f:` calls open(path).__enter__(), binds its return value to f, and calls __exit__(exc_type, exc_value, traceback) when the block ends, no matter how it ends: normal fall-through, return, break, or an exception. That guarantee is why with replaced try/finally for files, locks (with lock:), database transactions, and temporary state changes. Writing one class-based: implement __enter__ returning whatever `as` should bind, and __exit__ receiving the exception triple (all None on clean exit).
If __exit__ returns True the exception is suppressed, which is how contextlib.suppress(FileNotFoundError) works; returning None/False propagates it. The decorator route is shorter: @contextlib.contextmanager on a generator that yields exactly once; code before yield is setup, after is teardown, and you wrap the yield in try/finally so teardown survives exceptions inside the block. contextlib also ships closing() for objects with only a .close(), ExitStack for managing a dynamic number of contexts (opening N files where N is runtime-determined, with clean unwinding if the fifth open fails), and their async twins: asynccontextmanager and AsyncExitStack for `async with`, which is how FastAPI lifespan handlers and asyncpg transactions are written. Since Python 3.10 you can parenthesize multiple managers across lines: `with (open(a) as f, open(b) as g):`. A strong closing point for interviews: context managers are the Pythonic form of RAII, and any time you see paired verbs in an API (acquire/release, start/stop, begin/commit) the reviewer should be asking why it is not a context manager.
import time
from contextlib import contextmanager, ExitStack
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
return False # never suppress exceptions
with Timer() as t:
sum(range(1_000_000))
print(f'{t.elapsed:.4f}s')
@contextmanager
def db_transaction(conn):
conn.begin()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
# Dynamic number of contexts:
paths = ['a.txt', 'b.txt', 'c.txt']
with ExitStack() as stack:
files = [stack.enter_context(open(p, 'w')) for p in paths]
Q29What does `if __name__ == "__main__"` do, and how does Python's module import caching work?
BasicModules
Answer
Every module has a __name__ attribute. When a file runs as the entry script (python app.py), __name__ is the string "__main__"; when the same file is imported, __name__ is the module's dotted name. The guard therefore separates 'run this as a program' code from 'use this as a library' code: without it, importing the module for its functions would execute its script section, which is exactly what breaks multiprocessing on Windows and macOS (spawn) and, since Python 3.14, on Linux too (forkserver), because those start methods re-import your main module in each child process; missing the guard causes infinite process spawning with a RuntimeError about the bootstrapping phase.
Import caching is the second half of the question. The first import of a module executes its top-level code once and stores the module object in sys.modules; every later import anywhere in the process is a dictionary lookup returning the same object. Consequences: module-level code is effectively run-once initialization (which is why module-level singletons work), mutating a module attribute is visible to every importer, and a slow top-level import (opening DB connections, loading a model) taxes every process start, so heavy work belongs inside functions.
Reloading requires importlib.reload(module) and is best treated as a dev-only tool since old objects keep references to old classes. Also worth naming: `python -m package.module` runs a module by dotted path with __name__ set to "__main__" while keeping package-relative imports working, which is why `python -m pytest` and `python -m http.server` are the recommended invocation forms, and a package's __main__.py is what makes `python -m package` itself runnable.
# stats.py
import sys
print(f'importing, __name__={__name__}') # runs once per process
def mean(xs):
return sum(xs) / len(xs)
if __name__ == '__main__':
# only runs via `python stats.py` or `python -m stats`
args = [int(a) for a in sys.argv[1:]] or [10, 20, 30]
print(mean(args))
# elsewhere:
import stats # executes top-level code, caches in sys.modules
import stats # cache hit: nothing re-executes
print('stats' in sys.modules) # True
# multiprocessing spawn REQUIRES the guard:
# from multiprocessing import Pool
# if __name__ == '__main__':
# with Pool(4) as p:
# print(p.map(stats.mean, [[1, 2], [3, 4]]))
Q30What is the difference between a module and a package, and between `import x` and `from x import y`?
BasicModules
Answer
A module is a single .py file; a package is a directory of modules that Python treats as a namespace. A regular package has an __init__.py that runs when the package is imported and can re-export names; a namespace package (PEP 420) has no __init__.py and can be split across several directories. The dotted name you import mirrors the directory path: from app.services.payments import charge.
import x binds the module object to the name x, so you access members as x.y; it keeps names namespaced and makes it obvious where a function comes from. from x import y copies the binding for y into your namespace, which is shorter but risks name clashes and makes mocking harder, because tests must patch the name where it is used, not where it is defined. from x import * is discouraged outside interactive sessions because it hides what was imported and can shadow built-ins.
Two facts interviewers probe next: a module's top-level code runs once per process, because sys.modules caches it, and import order inside a package can create circular imports, which you fix by importing inside the function, moving the shared code to a third module, or importing the module instead of the name.
# project layout
# app/
# __init__.py -> from .config import settings (re-export)
# config.py
# services/
# __init__.py
# payments.py -> def charge(amount): ...
# Simulate the three import styles with the standard library
import json # namespaced: json.dumps
from json import dumps # direct name: dumps
import collections.abc as cabc # alias a submodule
print(json.dumps({'a': 1}) == dumps({'a': 1}))
print(isinstance([], cabc.Sequence))
import sys
print('json' in sys.modules) # cached after first import
print(json.__file__.endswith('json/__init__.py')) # json is a package
Key Points
- Module = one .py file; package = directory, usually with __init__.py
- import x keeps names namespaced; from x import y copies one binding
- Avoid from x import *; it hides names and shadows built-ins
- Top-level module code runs once per process via the sys.modules cache
Q31How do you manage virtual environments and dependencies in 2026: venv, pip, and uv?
BasicTooling
Answer
A virtual environment is a directory with its own interpreter shim and site-packages, isolating a project's dependencies from the system Python and from other projects. The stdlib way: `python -m venv .venv`, activate with `source .venv/bin/activate`, then `pip install -r requirements.txt`. That still works everywhere, but the 2026 default in serious teams is uv, Astral's Rust-based tool that replaces pip, venv, pip-tools, pipx, and pyenv in one binary and resolves/installs packages an order of magnitude faster.
Its project workflow: `uv init` creates a pyproject.toml; `uv add fastapi` adds a dependency and updates uv.lock, a cross-platform lockfile with hashes; `uv sync` reproduces the exact environment on any machine; `uv run pytest` runs commands inside the environment without manual activation; `uv python install 3.13` manages interpreter versions, replacing pyenv. The concepts an interviewer actually probes: the difference between direct dependencies (declared in pyproject.toml with version constraints like >=2,<3) and the lockfile (every transitive package pinned exactly), why lockfiles make builds reproducible and CI deterministic, and why installing into the system Python is banned (distro breakage, and Debian/Ubuntu now enforce PEP 668's externally-managed-environment error if you try). Also know requirements.txt still appears via `uv pip compile` or pip freeze for legacy deploy targets, and pipx (or `uv tool install`) is the right way to install CLI tools like ruff globally without polluting projects. If the round is DevOps-flavored, mention that Docker images should copy uv.lock and run `uv sync --locked` so the build fails loudly when uv.lock is out of date with pyproject.toml (`--frozen` installs from the lock without checking it, which is only safe after `--locked` has passed in CI).
# Classic stdlib flow
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Modern uv flow (2026 default)
uv init payments-api
cd payments-api
uv add 'fastapi>=0.110' httpx
uv add --dev pytest ruff mypy
uv sync # exact env from uv.lock, any OS
uv run pytest -q # no manual activation needed
uv python install 3.13 # interpreter management, replaces pyenv
# Reproducible container install
# Dockerfile:
# COPY pyproject.toml uv.lock ./
# RUN uv sync --locked --no-dev
Key Points
- venv isolates site-packages; never install into system Python (PEP 668)
- pyproject.toml declares ranges; the lockfile pins the full transitive graph
- uv replaces pip+venv+pyenv+pipx and is dramatically faster
- uv sync --locked in CI fails loudly on a stale lock; --frozen skips the check
Q32What are the four pillars of OOP, and how does Python implement each one?
BasicOOP
Answer
The four pillars are encapsulation, abstraction, inheritance and polymorphism. Python supports all four, but in its own way, and the interview is really about whether you know the Python way.
Encapsulation: Python has no private keyword. A single leading underscore marks an attribute as internal by convention, a double underscore triggers name mangling (__balance becomes _Account__balance), and @property lets you expose a computed or validated attribute behind plain attribute syntax. Abstraction: the abc module's ABC and @abstractmethod stop a class from being instantiated until the abstract methods are implemented; typing.Protocol gives structural (duck-typed) interfaces without inheritance. Inheritance: classes can inherit from several bases, with the C3 method resolution order deciding lookup, and super() walks that order. Polymorphism: mostly duck typing, where any object with a .area() method works, plus operator overloading through dunder methods such as __add__ and __len__, and method overriding in subclasses.
Interviewers at service companies ask for the definitions and one example each; product companies ask when you would prefer composition over inheritance, and why Protocol is often better than an ABC for library code.
from abc import ABC, abstractmethod
import math
class Shape(ABC): # abstraction
@abstractmethod
def area(self) -> float: ...
def describe(self): # inherited behaviour
return f'{type(self).__name__} with area {self.area():.2f}'
class Circle(Shape): # inheritance
def __init__(self, r):
self._r = r # encapsulation by convention
@property
def r(self):
return self._r
def area(self): # polymorphism via overriding
return math.pi * self._r ** 2
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
for s in (Circle(1), Square(2)): # same call, different behaviour
print(s.describe())
try:
Shape()
except TypeError as e:
print(e) # can't instantiate abstract class
Key Points
- Encapsulation: _single and __double underscore conventions, @property for controlled access
- Abstraction: abc.ABC + @abstractmethod, or typing.Protocol for structural interfaces
- Inheritance: multiple inheritance with C3 MRO, super() follows the MRO
- Polymorphism: duck typing, method overriding, operator overloading via dunder methods
Q33What is `self`, how do `__init__` and `__new__` differ, and what is the difference between class and instance attributes?
BasicOOP
Answer
self is the conventional name for the first parameter of an instance method and refers to the object the method was called on. Python passes it automatically: obj.method(x) is type(obj).method(obj, x). The name is a convention, not a keyword, but every linter and every colleague expects it.
__new__ is the static method that allocates and returns a new instance; __init__ then initialises that already-created instance and must return None. You override __init__ in almost every class and __new__ only for immutable types (subclassing int or tuple), singletons, or when you must decide which class to instantiate. If __new__ returns an object that is not an instance of the class, __init__ is skipped entirely.
Class attributes are defined in the class body and shared by every instance; instance attributes are set on self and belong to one object. Reading an attribute looks on the instance first, then the class, then the MRO. The classic bug is a mutable class attribute such as items = [] used as a per-instance list: every instance appends to the same list. The fix is to create it in __init__.
class Candidate:
count = 0 # class attribute, shared
def __new__(cls, *args, **kwargs):
print('allocating', cls.__name__)
return super().__new__(cls)
def __init__(self, name):
self.name = name # instance attribute
self.skills = [] # fresh list per instance
Candidate.count += 1
a, b = Candidate('Asha'), Candidate('Bilal')
a.skills.append('python')
print(b.skills, Candidate.count) # [] 2
print(Candidate.__init__(a, 'Asha2') is None, a.name) # self passed explicitly
class Buggy:
skills = [] # shared mutable class attribute
x, y = Buggy(), Buggy()
x.skills.append('sql')
print(y.skills) # ['sql'] <- leaked across instances
Key Points
- self is the instance; Python passes it automatically on obj.method()
- __new__ creates and returns the object; __init__ initialises it and returns None
- Override __new__ only for immutables, singletons or class selection
- Mutable class attributes are shared by all instances; create per-instance state in __init__
Q34How does Python handle private attributes, and what is duck typing versus an abstract base class?
BasicOOP
Answer
Python has no enforced privacy. A single leading underscore (_balance) is a convention that says 'internal, do not touch', and from module import * skips such names. A double leading underscore (__balance) triggers name mangling: the compiler rewrites it to _ClassName__balance, which prevents accidental overriding in subclasses but is still accessible if you know the mangled name. Use @property when you want attribute syntax with validation or a read-only view.
Duck typing means Python checks behaviour, not type: if an object has the methods you call, it works, regardless of its class. Most of the standard library is written this way (anything with __iter__ is iterable, anything with read() is file-like). The cost is that mistakes surface at call time as AttributeError rather than at construction.
An abstract base class (abc.ABC with @abstractmethod) makes the contract explicit: subclasses cannot be instantiated until they implement the abstract methods, and isinstance checks work. typing.Protocol sits between the two, defining a structural interface that type checkers verify without requiring inheritance. Choose Protocol for library boundaries, ABC when you also want shared implementation or runtime isinstance checks, and plain duck typing inside small modules.
class Account:
def __init__(self, balance):
self._balance = balance # convention: internal
self.__pin = '1234' # mangled to _Account__pin
@property
def balance(self): # read-only view
return self._balance
acc = Account(500)
print(acc.balance, acc._balance) # both work; the underscore is a hint
print(hasattr(acc, '__pin'), acc._Account__pin) # False 1234
# Duck typing: anything with .read() is a file to this function
import io
def first_line(f):
return f.read().splitlines()[0]
print(first_line(io.StringIO('hello\nworld')))
# Protocol: structural typing checked by mypy/pyright, no inheritance needed
from typing import Protocol, runtime_checkable
@runtime_checkable
class Readable(Protocol):
def read(self) -> str: ...
print(isinstance(io.StringIO('x'), Readable)) # True
Key Points
- _name: convention; __name: mangled to _Class__name; neither is enforced privacy
- @property gives validation or read-only access behind attribute syntax
- Duck typing: behaviour over type, errors surface at call time
- ABC enforces a contract at instantiation; Protocol enforces it in the type checker
Q35How can you make Python code faster? The checklist a fresher should know.
BasicPerformance
Answer
Measure first: time the slow part with time.perf_counter or timeit, or profile with cProfile, and only then optimise, because the bottleneck is almost never where you guess. Then apply the checklist in order of payoff.
Use the right data structure: set or dict for membership and lookup instead of a list (O(1) versus O(n)), deque for queues instead of list.pop(0), and Counter or defaultdict instead of hand-rolled counting. Use built-ins and comprehensions: sum, min, max, sorted, any, all and str.join run in C; string concatenation in a loop is quadratic, so collect parts in a list and join once. Avoid repeated work: hoist invariant computation out of loops, cache pure function results with functools.lru_cache, and compile a regular expression once. Use generators for large data so you never hold everything in memory. Reach for NumPy or pandas for numeric work, where a vectorised expression replaces a Python loop.
Beyond that, the levers are interpreter and architecture level: upgrade the Python version (3.11 onward is markedly faster), use multiprocessing for CPU-bound work and asyncio or threads for I/O-bound work, and move a hot function to a C extension, Cython or Rust only after profiling proves it is worth it. In an interview, answer with that sequence: measure, data structures, built-ins, caching, generators, NumPy, then concurrency and native code.
import timeit
ids = list(range(50_000))
ids_set = set(ids)
lookups = list(range(0, 50_000, 500))
print('list membership :', round(timeit.timeit(lambda: [x in ids for x in lookups], number=20), 3))
print('set membership :', round(timeit.timeit(lambda: [x in ids_set for x in lookups], number=20), 3))
def concat_loop(n):
s = ''
for i in range(n):
s += str(i)
return s
def concat_join(n):
return ''.join(str(i) for i in range(n))
print('concat in loop :', round(timeit.timeit(lambda: concat_loop(100_000), number=3), 3))
print('join once :', round(timeit.timeit(lambda: concat_join(100_000), number=3), 3))
from functools import lru_cache
@lru_cache(maxsize=None)
def slow_fib(n):
return n if n < 2 else slow_fib(n - 1) + slow_fib(n - 2)
print('cached fib(200) :', round(timeit.timeit(lambda: slow_fib(200), number=1), 6))
Key Points
- Measure first with timeit or cProfile; optimise the real hot spot
- Right data structure: set/dict lookups, deque queues, Counter and defaultdict
- Built-ins and comprehensions run in C; join strings once; cache with lru_cache
- Generators for memory, NumPy for numeric loops, then concurrency and native code
Q36Write a program to reverse a string, and then reverse the order of words in a sentence.
BasicCoding
Answer
The Pythonic answer is slicing: s[::-1] builds a reversed copy in O(n). Interviewers then ask for a version without slicing to see whether you can reason about indices or use the standard library: ''.join(reversed(s)) uses the reversed() iterator, and a manual loop accumulates characters into a list and joins once at the end (never concatenate strings in a loop, that is O(n squared) in the worst case because strings are immutable).
Reversing the order of words is a different problem: split on whitespace, reverse the list of words, and join with single spaces. split() with no argument also collapses repeated spaces and strips the ends, which is usually what the interviewer wants; say so explicitly, because preserving the original spacing would need split(' ') and more care.
State the complexity: all three string reversals are O(n) time and O(n) extra space, because Python strings cannot be reversed in place.
s = 'goodspace'
print(s[::-1]) # 'ecapsdoog' slicing
print(''.join(reversed(s))) # reversed() iterator
def reverse_manual(s):
out = []
for i in range(len(s) - 1, -1, -1):
out.append(s[i])
return ''.join(out) # join once: O(n)
print(reverse_manual(s))
def reverse_words(sentence):
return ' '.join(sentence.split()[::-1])
print(reverse_words(' python interview questions '))
# 'questions interview python'
Key Points
- s[::-1] is the expected first answer; reversed() and a loop show depth
- Build a list and join once; string concatenation in a loop is quadratic
- Word reversal: split(), reverse the list, join; split() collapses whitespace
- O(n) time and O(n) space, strings are immutable so no in-place reverse
Q37Check whether a string is a palindrome, ignoring case, spaces and punctuation.
BasicCoding
Answer
A palindrome reads the same forwards and backwards. The one-line version compares the string with its reverse: s == s[::-1]. Real interview inputs include 'A man, a plan, a canal: Panama', so first normalise: lower-case the string and keep only alphanumeric characters with str.isalnum(), then compare.
The follow-up is usually 'do it without extra space'. Use two pointers, one from each end, skipping non-alphanumeric characters and comparing lower-cased characters as you go. That is O(n) time and O(1) extra space, while the slicing version is O(n) space because it builds a reversed copy.
Mention the edge cases out loud: empty string and single character are palindromes, Unicode case folding (use casefold() for languages beyond ASCII), and that for a number you can either convert to str or reverse the digits arithmetically.
def is_palindrome(s):
cleaned = [c.lower() for c in s if c.isalnum()]
return cleaned == cleaned[::-1]
def is_palindrome_two_pointers(s):
i, j = 0, len(s) - 1
while i < j:
if not s[i].isalnum():
i += 1
elif not s[j].isalnum():
j -= 1
else:
if s[i].lower() != s[j].lower():
return False
i += 1
j -= 1
return True
for text in ['A man, a plan, a canal: Panama', 'Noida', '', 'x']:
print(repr(text), is_palindrome(text), is_palindrome_two_pointers(text))
# True True / False False / True True / True True
Key Points
- Normalise first: lower-case and keep isalnum() characters
- Slice version is O(n) space; two-pointer version is O(1) extra space
- Edge cases: empty string, one character, Unicode (casefold)
Q38Print the Fibonacci series up to n terms in three ways: loop, generator and memoised recursion.
BasicCoding
Answer
Fibonacci is asked to see whether you know when recursion is a bad idea. The iterative version keeps two variables and runs in O(n) time with O(1) space; that is the answer to write first. A generator version yields one term at a time and is what you would use in real code when the caller may stop early or n is large.
Naive recursion fib(n) = fib(n-1) + fib(n-2) is exponential, roughly O(1.6 to the power n), because it recomputes the same sub-problems. Adding functools.lru_cache (or functools.cache) turns it into O(n) by memoising results, which is the standard way to introduce dynamic programming in an interview. Be ready to say why the recursive version still fails for large n: CPython's default recursion limit is 1000 frames, so fib(5000) raises RecursionError even with a cache.
Clarify what 'up to n' means before coding: n terms, or all terms below the value n. Both interpretations appear in placement tests.
def fib_list(n):
a, b, out = 0, 1, []
for _ in range(n):
out.append(a)
a, b = b, a + b
return out
def fib_gen(): # infinite, stop with islice or break
a, b = 0, 1
while True:
yield a
a, b = b, a + b
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n): # memoised recursion: O(n)
return n if n < 2 else fib(n - 1) + fib(n - 2)
from itertools import islice
print(fib_list(10)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(list(islice(fib_gen(), 10)))
print([fib(i) for i in range(10)], fib(90))
print([x for x in fib_list(20) if x < 100]) # 'below 100' interpretation
Key Points
- Iterative: O(n) time, O(1) space, write this first
- Generator: lazy, infinite, pairs with itertools.islice
- Naive recursion is exponential; lru_cache makes it O(n)
- Recursion limit (default 1000) still caps the recursive version
Q39Check whether a number is prime, then generate all primes below n efficiently.
BasicCoding
Answer
For a single number, trial division up to the square root is enough: if n has a factor larger than sqrt(n), it also has one smaller, so you only test divisors up to that point. Handle n < 2 (not prime), 2 (the only even prime), and then test odd divisors only. That is O(sqrt(n)).
For all primes below n, do not call is_prime in a loop; that is O(n sqrt(n)). Use the Sieve of Eratosthenes: start with a boolean list marked True, and for each prime p from 2 to sqrt(n), mark every multiple of p from p squared as composite. The sieve runs in O(n log log n) and is the answer that distinguishes practised candidates in service-company coding tests.
Mention memory: the sieve uses O(n) booleans, so for n in the billions you would segment it. And mention math.isqrt() for the integer square root instead of int(n ** 0.5), which can be off by one for large n.
import math
def is_prime(n):
if n < 2:
return False
if n % 2 == 0:
return n == 2
for d in range(3, math.isqrt(n) + 1, 2):
if n % d == 0:
return False
return True
def primes_below(n):
if n < 3:
return []
sieve = [True] * n
sieve[0] = sieve[1] = False
for p in range(2, math.isqrt(n - 1) + 1):
if sieve[p]:
sieve[p * p::p] = [False] * len(range(p * p, n, p))
return [i for i, flag in enumerate(sieve) if flag]
print([x for x in range(20) if is_prime(x)]) # [2, 3, 5, 7, 11, 13, 17, 19]
print(primes_below(30)) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
print(is_prime(1_000_003)) # True
Key Points
- Single check: trial division up to isqrt(n), odd divisors only, O(sqrt n)
- Many primes: Sieve of Eratosthenes, O(n log log n), mark from p squared
- Use math.isqrt, not int(n ** 0.5), for exact integer roots
Q40Write factorial iteratively and recursively, and solve FizzBuzz without repeating yourself.
BasicCoding
Answer
Factorial and FizzBuzz are warm-ups, so the interviewer is listening for how you talk, not just whether it runs. For factorial, give the loop (O(n), no recursion risk), the recursive definition with its base case, and then say that in real code you would call math.factorial, which is implemented in C. Point out that Python integers do not overflow, so factorial(100) is a 158-digit exact result, and that recursion depth is capped by sys.getrecursionlimit().
FizzBuzz: for 1 to n print Fizz for multiples of 3, Buzz for multiples of 5, FizzBuzz for both. The trap is checking 3 and 5 separately and forgetting the combined case, or writing three if statements with duplicated conditions. The clean version builds the string by concatenation: append Fizz if divisible by 3, append Buzz if divisible by 5, and print the number if the string is still empty. It extends to extra rules (7 means Bazz) without rewriting.
import math
def factorial_iter(n):
result = 1
for i in range(2, n + 1):
result *= i
return result
def factorial_rec(n):
if n < 0:
raise ValueError('n must be >= 0')
return 1 if n in (0, 1) else n * factorial_rec(n - 1)
print(factorial_iter(5), factorial_rec(5), math.factorial(5)) # 120 120 120
print(len(str(math.factorial(100)))) # 158 digits
def fizzbuzz(n, rules=((3, 'Fizz'), (5, 'Buzz'))):
for i in range(1, n + 1):
word = ''.join(name for div, name in rules if i % div == 0)
print(word or i)
fizzbuzz(15)
Key Points
- Factorial: loop first, recursion with base case, math.factorial in production
- Python ints never overflow; recursion depth is the real limit
- FizzBuzz: build the word by concatenation so the 15 case needs no special branch
Q41Remove duplicates from a list while keeping order, and find which elements are duplicated.
BasicCoding
Answer
list(set(items)) removes duplicates but loses order and only works for hashable items, so it is the wrong first answer in an interview. The idiomatic order-preserving version is list(dict.fromkeys(items)): dict keys are unique and insertion-ordered since Python 3.7, and the whole thing is O(n). The explicit version walks the list once with a 'seen' set.
To find which elements repeat, keep two sets: seen and duplicates. Each element is checked against seen in O(1), so the whole scan is O(n), versus the nested-loop version that is O(n squared). collections.Counter gives the same answer plus the counts, which is what you want when the follow-up is 'and how many times does each repeat?'.
If the items are unhashable (lists of lists), either convert each to a tuple as the key or fall back to an O(n squared) scan and say why.
from collections import Counter
items = [3, 1, 3, 2, 1, 5, 3]
print(list(dict.fromkeys(items))) # [3, 1, 2, 5] order kept
def dedupe(items):
seen, out = set(), []
for x in items:
if x not in seen:
seen.add(x)
out.append(x)
return out
def find_duplicates(items):
seen, dupes = set(), set()
for x in items:
if x in seen:
dupes.add(x)
seen.add(x)
return sorted(dupes)
print(dedupe(items), find_duplicates(items)) # [3, 1, 2, 5] [1, 3]
print({k: v for k, v in Counter(items).items() if v > 1}) # {3: 3, 1: 2}
rows = [[1, 2], [3, 4], [1, 2]] # unhashable items
print(list({tuple(r): r for r in rows}.values())) # [[1, 2], [3, 4]]
Key Points
- list(dict.fromkeys(x)) de-duplicates in O(n) and keeps first occurrence
- list(set(x)) loses order; say so before using it
- Two-set scan finds duplicates in O(n); Counter adds the counts
- Unhashable items: convert to tuple keys or accept O(n squared)
Q42Count word frequency in a text and return the k most common words; also count vowels in a string.
BasicCoding
Answer
Word frequency is a dict problem. The explicit version loops over the words and does counts[word] = counts.get(word, 0) + 1; the idiomatic version is collections.Counter, which is a dict subclass with most_common(k). Normalise before counting: lower-case the text and strip punctuation with str.translate or a regular expression, otherwise 'Python' and 'python,' count separately.
Counter.most_common() with no argument sorts everything, O(n log n); with k it uses heapq.nlargest under the hood, O(n log k), which is the complexity to quote if the interviewer pushes. For ties, most_common returns items in first-seen order, which is usually acceptable, but say it.
Vowel counting is a one-liner with sum() over a generator expression and a set of vowels for O(1) membership. It is a common warm-up in service-company rounds, so write it without hesitation and move on.
import re
from collections import Counter
text = 'Python is easy. Python is powerful, and python is everywhere!'
def word_counts(text):
words = re.findall(r"[a-z0-9']+", text.lower())
counts = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
return counts
print(word_counts(text))
counter = Counter(re.findall(r"[a-z0-9']+", text.lower()))
print(counter.most_common(2)) # [('python', 3), ('is', 3)]
import heapq
print(heapq.nlargest(2, counter.items(), key=lambda kv: kv[1]))
VOWELS = set('aeiou') # build the set once, not per character
def count_vowels(s):
return sum(1 for c in s.lower() if c in VOWELS)
print(count_vowels('GoodSpace Interview')) # 8
Key Points
- dict.get(word, 0) + 1 is the manual version; Counter is the idiomatic one
- Normalise case and punctuation before counting
- most_common() sorts (O(n log n)); most_common(k) uses heapq.nlargest (O(n log k))
- Vowel count: sum over a generator with a set for membership
Q43Check whether two strings are anagrams, then group a list of words by anagram.
BasicCoding
Answer
Two strings are anagrams when they contain the same characters with the same counts. The quickest correct check is sorted(a) == sorted(b), which is O(n log n). The O(n) version compares character counts with collections.Counter, or with a fixed-size array of 26 counts when the input is lower-case ASCII. Normalise case and strip spaces first if the interviewer says 'Dormitory' and 'dirty room' should match.
Grouping anagrams is the natural follow-up and a frequent online-test question: use a dict whose key is a canonical form of each word, either the sorted string or a tuple of 26 counts, and append each word to its group. collections.defaultdict(list) removes the 'if key not in dict' boilerplate. Sorting each word costs O(k log k) for word length k, so the whole thing is O(n k log k); the count-tuple key makes it O(n k).
Say the trade-off: the sorted-string key is simpler and fine for interviews, the count key is what you would use at scale.
from collections import Counter, defaultdict
def is_anagram(a, b):
a, b = a.replace(' ', '').lower(), b.replace(' ', '').lower()
return Counter(a) == Counter(b) # O(n)
print(is_anagram('listen', 'silent'), is_anagram('Dormitory', 'dirty room'))
print(sorted('listen') == sorted('silent')) # O(n log n) alternative
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
groups[''.join(sorted(w))].append(w)
return list(groups.values())
print(group_anagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
def count_key(w): # O(k) key for lower-case ASCII
counts = [0] * 26
for c in w:
counts[ord(c) - ord('a')] += 1
return tuple(counts)
print(count_key('aab'))
Key Points
- sorted(a) == sorted(b) is O(n log n); Counter comparison is O(n)
- Group with a dict keyed by a canonical form (sorted string or count tuple)
- defaultdict(list) removes the key-exists check
Q44Find the second largest number in a list without sorting, and the first non-repeating character in a string.
BasicCoding
Answer
Second largest is a one-pass problem. Keep two variables, first and second, both initialised to negative infinity. For each number: if it is greater than first, shift first into second and take the new value; else if it is greater than second and not equal to first, update second. That handles duplicates ([5, 5, 3] gives 3) in O(n) time and O(1) space, which beats sorting (O(n log n)) and the cute but wrong sorted(set(x))[-2], which fails on lists with fewer than two distinct values and still sorts.
Decide the edge cases aloud: a list with fewer than two distinct values has no second largest, so return None or raise ValueError, and say which you chose.
First non-repeating character is the same shape: count every character once with Counter, then scan the string in order and return the first character whose count is 1. Two passes, O(n). The one-pass variant with an ordered dict is possible but not shorter.
def second_largest(nums):
first = second = float('-inf')
for n in nums:
if n > first:
first, second = n, first
elif first > n > second:
second = n
return None if second == float('-inf') else second
print(second_largest([10, 5, 20, 20, 8])) # 10
print(second_largest([5, 5, 5])) # None
print(second_largest([3, 7])) # 3
from collections import Counter
def first_unique_char(s):
counts = Counter(s)
for c in s:
if counts[c] == 1:
return c
return None
print(first_unique_char('goodspace')) # 'g'
print(first_unique_char('aabb')) # None
Key Points
- Track first and second in one pass; O(n) time, O(1) space
- Handle duplicates with first > n > second; define behaviour for fewer than two distinct values
- First unique character: Counter, then scan in order
Q45Two Sum: return the indices of two numbers that add up to a target, in O(n).
BasicCoding
Answer
Two Sum is one of the most common coding questions in Python screening tests because it checks whether you reach for a hash map. The brute force compares every pair, O(n squared). The O(n) solution walks the list once, and for each number checks whether target minus number has already been seen in a dict that maps value to index; if yes, return both indices, otherwise store the current number.
Order matters: check the complement before inserting the current number, or [3, 3] with target 6 would match an element with itself. Say what you return when there is no answer (an empty list or None) and whether multiple answers are possible.
If the input is sorted, the two-pointer technique gives O(n) time with O(1) space, which is the follow-up interviewers use to see whether you can trade memory for a precondition. If they ask for all pairs or for three numbers (3Sum), sort first and use two pointers inside a loop.
def two_sum(nums, target):
seen = {} # value -> index
for i, n in enumerate(nums):
if target - n in seen:
return [seen[target - n], i]
seen[n] = i
return []
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum([3, 3], 6)) # [0, 1]
print(two_sum([1, 2], 10)) # []
def two_sum_sorted(nums, target): # O(1) extra space, needs sorted input
i, j = 0, len(nums) - 1
while i < j:
s = nums[i] + nums[j]
if s == target:
return [i, j]
if s < target:
i += 1
else:
j -= 1
return []
print(two_sum_sorted([1, 3, 4, 6, 10], 10)) # [2, 3]
Key Points
- Dict of value to index gives O(n) time, O(n) space
- Check the complement before storing the current number
- Sorted input: two pointers, O(n) time, O(1) space
Q46Classic Python interview programs asked in placement rounds: Armstrong number, leap year, GCD and LCM, digit sum and reverse, star patterns, bubble sort.
BasicCoding
Answer
Service-company placement rounds and most 'python interview programs' question banks draw from the same short list. You are expected to write each in under five minutes without hesitation, and the interviewer listens for the one idea behind each: Armstrong number (sum of each digit raised to the number of digits equals the number itself), leap year (divisible by 4, except centuries, unless divisible by 400), GCD by Euclid's algorithm with LCM as a * b // gcd, digit sum and digit reversal with // and % (or str tricks), star and number patterns with nested loops and string multiplication, and bubble sort with the early-exit flag.
Say the standard-library shortcut after the manual version: math.gcd and math.lcm exist since 3.9, calendar.isleap(year) exists, and sorted() is what you would use in real code. Showing both proves you can implement the logic and that you would not reinvent it at work.
Complexity to state: digit algorithms are O(number of digits), the GCD loop is O(log min(a, b)), patterns are O(n squared) prints, bubble sort is O(n squared) with O(1) extra space.
import math, calendar
def is_armstrong(n):
digits = str(n)
return n == sum(int(d) ** len(digits) for d in digits)
def is_leap(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def gcd(a, b):
while b:
a, b = b, a % b
return a
def digit_sum_and_reverse(n):
total, rev = 0, 0
while n:
n, d = divmod(n, 10)
total += d
rev = rev * 10 + d
return total, rev
def bubble_sort(xs):
xs = list(xs)
for i in range(len(xs) - 1):
swapped = False
for j in range(len(xs) - 1 - i):
if xs[j] > xs[j + 1]:
xs[j], xs[j + 1] = xs[j + 1], xs[j]
swapped = True
if not swapped: # already sorted: early exit
break
return xs
print([n for n in range(1, 500) if is_armstrong(n)]) # [1..9, 153, 370, 371, 407]
print(is_leap(2024), is_leap(1900), is_leap(2000), calendar.isleap(2024))
print(gcd(48, 18), math.gcd(48, 18), 48 * 18 // gcd(48, 18), math.lcm(48, 18))
print(digit_sum_and_reverse(1234)) # (10, 4321)
print(bubble_sort([5, 1, 4, 2, 8])) # [1, 2, 4, 5, 8]
for i in range(1, 5): # right-angle star pattern
print('* ' * i)
for i in range(1, 4): # number pyramid
print(' ' * (3 - i) + ' '.join(str(d) for d in range(1, i + 1)))
Key Points
- Armstrong: sum of digits to the power of digit count; leap year: 4, not 100, unless 400
- GCD by Euclid, LCM = a * b // gcd; math.gcd and math.lcm in real code
- Digit sum and reverse with divmod(n, 10); patterns with nested loops and string multiplication
- Bubble sort with early exit; say O(n squared) and that sorted() is what you use at work
Q47Python interview questions for freshers: 25 practice problems to solve before the round, with expected outputs.
BasicPractice
Answer
Reading answers is not practice. Solve these 25 problems yourself in a plain editor, timed, and check the expected output. They are grouped by the data structure each one trains and cover what fresher rounds at both service and product companies actually ask. Solutions for many of them are elsewhere on this page, so attempt first and only then compare.
Strings: 1. Reverse 'interview' without slicing (expected: weivretni). 2. Check whether 'Madam' is a palindrome ignoring case (True). 3. Count vowels in 'GoodSpace' (4). 4. Find the first non-repeating character in 'swiss' (w). 5. Check if 'listen' and 'silent' are anagrams (True). 6. Compress 'aaabbc' to 'a3b2c1'. 7. Capitalise the first letter of every word in 'python interview questions' without title() (Python Interview Questions).
Lists and dicts: 8. Second largest in [4, 9, 9, 2] without sorting (4). 9. Remove duplicates from [1, 2, 2, 3, 1] keeping order ([1, 2, 3]). 10. Rotate [1, 2, 3, 4, 5] right by 2 ([4, 5, 1, 2, 3]). 11. Word frequency of 'a b a c b a' as a dict ({'a': 3, 'b': 2, 'c': 1}). 12. Merge {'x': 1} and {'y': 2, 'x': 3} so the second wins ({'x': 3, 'y': 2}). 13. Invert {'a': 1, 'b': 2} to {1: 'a', 2: 'b'}. 14. Two Sum on [2, 7, 11, 15] with target 9 ([0, 1]). 15. Flatten [[1, 2], [3, [4]]] to [1, 2, 3, 4]. 16. Transpose [[1, 2, 3], [4, 5, 6]] using zip ([[1, 4], [2, 5], [3, 6]]).
Numbers and logic: 17. Primes below 20 ([2, 3, 5, 7, 11, 13, 17, 19]). 18. Fibonacci first 8 terms ([0, 1, 1, 2, 3, 5, 8, 13]). 19. Factorial of 6 (720). 20. Is 153 an Armstrong number (True). 21. GCD of 36 and 60 (12). 22. FizzBuzz for 1 to 15 (ends with 14, FizzBuzz). 23. Sum of digits of 9875 (29). 24. Binary search for 7 in [1, 3, 5, 7, 9] (index 3). 25. Balanced brackets for '{[()]}' (True) and '([)]' (False).
Target: 15 of 25 correct in 60 minutes on the first attempt; all 25 in 45 minutes a week later. Then do the output questions above and move to the intermediate coding set.
# Self-check harness: paste your solutions as functions and run.
def check(label, got, expected):
status = 'ok ' if got == expected else 'FAIL'
print(f'{status} {label}: got {got!r}, expected {expected!r}')
# Example solutions for 1, 6, 10 and 16 so you can see the format
def reverse_no_slice(s):
out = ''
for ch in s:
out = ch + out
return out
def compress(s):
out, i = [], 0
while i < len(s):
j = i
while j < len(s) and s[j] == s[i]:
j += 1
out.append(f'{s[i]}{j - i}')
i = j
return ''.join(out)
def rotate_right(xs, k):
k %= len(xs)
return xs[-k:] + xs[:-k] if k else list(xs)
def transpose(m):
return [list(col) for col in zip(*m)]
check('1 reverse', reverse_no_slice('interview'), 'weivretni')
check('6 compress', compress('aaabbc'), 'a3b2c1')
check('10 rotate', rotate_right([1, 2, 3, 4, 5], 2), [4, 5, 1, 2, 3])
check('16 transpose', transpose([[1, 2, 3], [4, 5, 6]]), [[1, 4], [2, 5], [3, 6]])
Key Points
- 25 problems across strings, lists and dicts, numbers and logic, each with an expected output
- Solve timed in a plain editor, then compare with the worked answers on this page
- Target 15 of 25 on the first attempt, all 25 a week later
- Use the check() harness so you verify outputs instead of eyeballing them
Q48Output questions, set 1: what do these snippets print, and why? (mutable defaults, nested list multiplication, integer caching, string immutability)
BasicOutput Questions
Answer
Output-prediction questions are the backbone of service-company Python rounds and online tests. Each one tests a single model of how objects and names work, so answer with the rule, not just the output.
Mutable default: def add(x, items=[]) creates the list once at definition time, so successive calls share it, so a second call returns [1, 2] (and because both calls run before print formats its arguments, the snippet prints [1, 2] twice). Nested list multiplication: [[0] * 3] * 3 copies the same inner list reference three times, so changing grid[0][0] changes every row; build rows in a comprehension instead. Integer caching: CPython pre-allocates small integers (-5 to 256), so a is b is True for 100 and may be False for 1000 when the objects are created separately at runtime; never use is for value comparison. String immutability: s.upper() returns a new string and the original is unchanged unless you rebind the name, and s[0] = 'X' raises TypeError.
In each case, the sentence the interviewer wants is 'names are references to objects; the default list, the inner list and the small int are single objects being shared'.
# 1. Mutable default argument
def add(x, items=[]):
items.append(x)
return items
print(add(1), add(2)) # [1, 2] [1, 2] (same list, printed after both calls)
# 2. Nested list multiplication
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
safe = [[0] * 3 for _ in range(3)]
safe[0][0] = 1
print(safe) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
# 3. Small integer caching
a, b = 100, 100
print(a is b) # True (cached)
x = int('1000'); y = int('1000')
print(x == y, x is y) # True False (separate objects)
# 4. String immutability
s = 'python'
s.upper()
print(s) # 'python' (unchanged)
try:
s[0] = 'P'
except TypeError as e:
print(e) # 'str' object does not support item assignment
Key Points
- Defaults are evaluated once at def time; use None and create inside
- [[0]*3]*3 shares one inner list; use a comprehension
- Small ints -5 to 256 are cached; use == for values, is only for None
- String methods return new strings; the original never changes
Q49Output questions, set 2: late-binding closures in a loop, modifying a dict while iterating, what `and`/`or` return, and chained comparisons.
BasicOutput Questions
Answer
Late binding: lambdas created in a loop capture the variable i, not its value, so after the loop every lambda sees the final i and [f() for f in funcs] prints [2, 2, 2]. Fix it with a default argument (lambda i=i: i) or functools.partial. Dict mutation during iteration: adding or removing keys inside for k in d raises RuntimeError: dictionary changed size during iteration; iterate over list(d) or build a new dict instead. Changing values of existing keys is fine.
and and or do not return booleans; they return one of their operands. x or y returns x if x is truthy, else y, which is why name or 'default' works; x and y returns x if x is falsy, else y. So 0 or 'a' is 'a', 'a' and 0 is 0, and [] or {} is {}. Chained comparisons: 1 < x < 10 means (1 < x) and (x < 10) with x evaluated once, so 1 < 5 > 3 is True and the surprising False == False in [False] is (False == False) and (False in [False]), which is True.
Each of these is typical of service-company Python assessments. Run them once yourself before the test; the outputs stick far better than reading them.
# 1. Late binding in closures
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]
# 2. Mutating a dict while iterating
d = {'a': 1, 'b': 2, 'c': 3}
try:
for k in d:
if d[k] == 2:
del d[k]
except RuntimeError as e:
print(e) # dictionary changed size during iteration
for k in list(d):
if d[k] == 2:
del d[k]
print(d) # {'a': 1, 'c': 3}
# 3. and / or return operands
print(0 or 'a', 'a' and 0, [] or {}, 3 and 4) # a 0 {} 4
# 4. Chained comparisons
x = 5
print(1 < x < 10, 1 < 5 > 3) # True True
print(False == False in [False]) # True
Key Points
- Closures capture variables, not values; bind with a default argument
- Never change a dict's size while iterating; iterate over list(d)
- and/or return an operand, not a bool; x or default is the idiom
- a < b < c is (a < b) and (b < c) with b evaluated once
Q50Write a decorator with arguments. Why does functools.wraps matter, and how do you decorate while preserving type hints?
IntermediateDecorators
Answer
A decorator is a callable that takes a function and returns a replacement; @decorator above a def is sugar for func = decorator(func) at definition time. A parameterized decorator adds one more layer: @retry(times=3) first CALLS retry(times=3), which must return the actual decorator, which returns the wrapper, three nested functions total. functools.wraps(fn) on the wrapper copies __name__, __doc__, __module__, __qualname__, and __wrapped__ from the original; without it every decorated function reports its name as 'wrapper', which breaks logging, pickling (multiprocessing can no longer find the function by qualified name), Sphinx docs, and debugging in general; @wraps also sets __wrapped__ so inspect.unwrap can reach the original. Interviewers commonly probe three follow-ups.
Execution time: the decorator body runs once at import; the wrapper runs per call, so put expensive setup in the decorator layer and keep the wrapper lean. State: decorators that count calls or rate-limit need somewhere to keep state, closure variables with nonlocal, function attributes, or a class-based decorator implementing __call__. Typing: a naive wrapper types as Callable[..., Any], destroying autocomplete; the fix is ParamSpec (3.10+): `def decorator[**P, R](fn: Callable[P, R]) -> Callable[P, R]` with the 3.12 syntax (the type parameters go on the inner function that receives fn, not on the outer factory), which preserves the exact signature through the decorator.
Real production decorators to reference: functools.lru_cache, tenacity's @retry, Flask's @app.route (which registers rather than wraps), and @login_required in Django. Mention that stacked decorators apply bottom-up, the one closest to def runs first at decoration time and outermost at call time.
import functools, time
from typing import Callable
def retry(times: int = 3, delay: float = 0.2):
if times < 1:
raise ValueError('times must be >= 1')
def decorator[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc = None
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except (TimeoutError, ConnectionError) as exc:
last_exc = exc
time.sleep(delay * attempt) # linear backoff
raise last_exc
return wrapper
return decorator
@retry(times=5, delay=0.5)
def call_gateway(order_id: str) -> dict:
...
print(call_gateway.__name__) # 'call_gateway' thanks to wraps
print(call_gateway.__wrapped__) # original function reachable
Key Points
- @decorator is func = decorator(func) at import time
- Parameterized decorators are a factory returning a decorator: three layers
- functools.wraps preserves identity; skipping it breaks pickling and logs
- ParamSpec (or 3.12 [**P, R] syntax) keeps the wrapped signature typed
Q51What is a closure, and how do closures power decorators and function factories?
IntermediateDecorators
Answer
A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished running. When an inner function references a name from the enclosing function, Python stores that reference in the inner function's __closure__ as a cell, so the value survives the outer call returning. Closures are how Python gets private state without classes.
Function factories use this directly: make_multiplier(3) returns a function that remembers 3. Decorators are closures too: the wrapper function closes over the decorated function so it can call it later, which is why a decorator with arguments is three nested functions, one per level of state. Rebinding a captured variable needs the nonlocal keyword; without it the assignment creates a new local and the counter never increments.
The gotcha that interviewers pair with closures is late binding: the closure captures the variable, not its value at creation time, so lambdas created in a loop all see the loop variable's final value. Bind the value with a default argument or functools.partial.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor lives in a closure cell
return multiply
double, triple = make_multiplier(2), make_multiplier(3)
print(double(5), triple(5)) # 10 15
print(double.__closure__[0].cell_contents) # 2
def make_counter():
count = 0
def increment():
nonlocal count # rebind the enclosing variable
count += 1
return count
return increment
c = make_counter()
print(c(), c(), c()) # 1 2 3
import functools, time
def retry(times): # decorator factory: closure over `times`
def decorator(fn): # closure over `fn`
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except ValueError:
if attempt == times:
raise
return wrapper
return decorator
calls = []
@retry(times=3)
def flaky():
calls.append(1)
if len(calls) < 3:
raise ValueError('not yet')
return 'ok'
print(flaky(), len(calls)) # ok 3
Key Points
- Inner function keeps enclosing variables alive in __closure__ cells
- nonlocal is required to rebind a captured variable
- Decorators are closures over the wrapped function; factories add one more level
- Late binding: loop lambdas capture the variable, bind with a default argument
Q52How do generators work under the hood: yield, send(), close(), and yield from?
IntermediateGenerators
Answer
Calling a function containing yield does not run its body; it returns a generator object whose frame is frozen. Each next() resumes the frame until the next yield, which produces a value and suspends, preserving locals and the instruction pointer. When the body returns, the generator raises StopIteration with the return value attached to its .value.
This suspend/resume machinery is the substrate for three progressively deeper capabilities. First, streaming pipelines: chained generators process unbounded data in constant memory, and each stage pulls from the previous one lazily, so `total = sum(parse(line) for line in read_lines(path))` never holds the file in memory. Second, two-way communication: gen.send(value) resumes the generator AND makes the paused yield expression evaluate to that value, enabling coroutine-style consumers (you must prime with next() first or send(None)); gen.throw(exc) raises inside the frame at the yield point; gen.close() raises GeneratorExit there, which is why try/finally inside a generator is how you guarantee resource cleanup when a consumer abandons iteration early, a real bug source when a caller breaks out of a loop over a generator holding an open file or DB cursor.
Third, delegation: `yield from subgen` (PEP 380) transparently forwards iteration, send, throw, and close to the subgenerator and evaluates to its return value, which is what made composing generators practical and was the direct ancestor of await. Historically, asyncio coroutines were generators driven by an event loop via yield from; async/await formalized that into a separate protocol, but explaining the lineage is exactly what a senior interviewer wants to hear. PEP 479 note: raising StopIteration inside a generator body becomes RuntimeError, so signal completion with return, never a manual StopIteration.
def running_avg():
total, count = 0.0, 0
avg = None
while True:
value = yield avg # send() makes this expression = sent value
total += value
count += 1
avg = total / count
acc = running_avg()
next(acc) # prime to first yield
print(acc.send(10)) # 10.0
print(acc.send(30)) # 20.0
def read_batches(rows, size):
batch = []
try:
for r in rows:
batch.append(r)
if len(batch) == size:
yield batch
batch = []
if batch:
yield batch
finally:
print('cleanup runs even if consumer breaks early')
def flatten(list_of_lists):
for inner in list_of_lists:
yield from inner # full delegation
print(list(flatten([[1, 2], [3]]))) # [1, 2, 3]
Q53What exactly is the GIL, what does it protect, and what is the status of free-threaded Python?
IntermediateGIL
Answer
The Global Interpreter Lock is a mutex in CPython that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management relies on reference counting, and unguarded concurrent refcount updates from multiple threads would corrupt memory; one big lock made single-threaded code fast and C extensions simple. Consequences you must state precisely: threads still help for I/O-bound work, because the GIL is released around blocking I/O (socket reads, file operations, time.sleep, most database driver calls), so ten threads waiting on ten HTTP responses genuinely overlap.
Threads do NOT help CPU-bound pure-Python work; two threads hashing passwords take as long as one, sometimes longer due to lock contention. C extensions like NumPy, and hashlib on large buffers, release the GIL during heavy native computation, which is why a NumPy matrix multiply can parallelize across threads even under the GIL. The classic escape hatch for CPU parallelism is multiprocessing or concurrent.futures.ProcessPoolExecutor, paying serialization and memory costs.
The current status (important to get right in 2026): PEP 703 produced a free-threaded build of CPython, experimental in 3.13 (the python3.13t executable) and officially supported as of 3.14 per PEP 779, but it is a separate build, not the default; it uses biased reference counting and per-object locks, carries a single-digit-percentage single-thread overhead, and requires C extensions to declare free-threading compatibility (many major ones now do, others are in progress). Python 3.15 added a stable ABI for free-threaded builds (PEP 803) and the official macOS installers now include free-threading support, which is what makes wheel availability improve quickly. Separately, PEP 684 gave each subinterpreter its own GIL, exposed via concurrent.interpreters in 3.14. The correct interview posture: the GIL is a CPython implementation detail (Jython has none, and other implementations make their own trade-offs), it is genuinely being dismantled, and today you pick threads for I/O, processes for CPU, and watch free-threaded builds for compute-heavy multithreaded futures.
Key Points
- GIL protects refcounting; one thread runs bytecode at a time
- Released on blocking I/O: threads work for I/O-bound loads
- NumPy/hashlib release it in native code; pure-Python CPU work does not scale on threads
- PEP 703: free-threaded build, experimental 3.13, supported 3.14, still opt-in
- PEP 684/734: per-interpreter GIL + concurrent.interpreters
Q54threading versus multiprocessing versus asyncio: how do you choose for a given workload?
IntermediateConcurrency
Answer
Classify the workload first. CPU-bound (parsing, image processing, ML feature computation): multiprocessing or ProcessPoolExecutor, because separate processes have separate GILs and use all cores; costs are process startup, pickling of arguments and results (a function must be importable at module top level to pickle, which is why lambdas fail), and no shared memory by default (use multiprocessing.shared_memory or Queue/Pipe to communicate). Also know the start methods: fork (Linux default historically, fast but unsafe with threads and locked resources), spawn (default on macOS and Windows; it re-imports your module in each child and so requires the __main__ guard), and forkserver (the default on Linux as of Python 3.14, replacing fork because fork is unsafe once threads exist; like spawn it needs the __main__ guard).
I/O-bound with moderate concurrency (tens of parallel calls, or a blocking client library): threading or ThreadPoolExecutor; the GIL releases on I/O, and the code stays synchronous and simple. I/O-bound with high concurrency (thousands of sockets, websockets, scraping, chat backends): asyncio; a single-threaded event loop switches between coroutines at await points, so context switches are cheap and there are no per-connection thread stacks. The constraint is viral: everything in the hot path must be async-aware, and one blocking call (requests.get, time.sleep, a sync DB driver) stalls every coroutine on the loop.
Bridge patterns matter in practice: asyncio.to_thread() (3.9+) pushes a blocking call onto a worker thread from async code; loop.run_in_executor with a ProcessPoolExecutor pushes CPU work out of the loop. Real-world mapping: a large Django monolith runs sync workers (threads/processes via gunicorn); FastAPI services and websocket gateways run uvicorn's event loop; a video transcoding worker runs a process pool. Saying 'async is faster' unqualified is a red flag; async wins on concurrency and memory, not raw single-request speed.
import asyncio, time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def hash_cpu(data: bytes) -> str: # CPU-bound: processes
import hashlib
return hashlib.sha256(data * 10_000).hexdigest()
def fetch_blocking(url: str) -> int: # blocking I/O: threads
import urllib.request
with urllib.request.urlopen(url, timeout=5) as r:
return r.status
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
digest = await loop.run_in_executor(pool, hash_cpu, b'payload')
# blocking client inside async code without stalling the loop:
status = await asyncio.to_thread(fetch_blocking, 'https://example.com')
# pure-async high concurrency:
async def ping(i):
await asyncio.sleep(0.1) # stands in for aiohttp call
return i
results = await asyncio.gather(*(ping(i) for i in range(1000)))
print(digest[:8], status, len(results))
if __name__ == '__main__':
asyncio.run(main())
Q55How do ThreadPoolExecutor and ProcessPoolExecutor work, and what are the pitfalls of Future-based code?
IntermediateConcurrency
Answer
concurrent.futures gives one high-level API over both threads and processes. executor.submit(fn, *args) schedules a call and immediately returns a Future, a handle with .result(timeout=None), .exception(), .done(), and .add_done_callback(). executor.map(fn, iterable) is the bulk form, returning results in INPUT order, while as_completed(futures) yields futures in COMPLETION order, which is what you want for showing progress or handling fast responses first; that distinction is a standard probe. Sizing: ThreadPoolExecutor defaults to min(32, os.cpu_count() + 4) workers, tuned for I/O; for HTTP fan-out you often raise it, for a process pool you cap at cpu_count() because more brings no CPU. Pitfalls that decide the question.
Silent exceptions: an exception inside a worker is stored on the Future and re-raised only when you call .result(); fire-and-forget submissions swallow errors invisibly, so always consume results or attach done-callbacks that log. Timeouts: future.result(timeout=10) raises TimeoutError but does NOT kill the worker; the task keeps running and can hold the pool hostage, so cancellation needs cooperative design (a threading.Event the task checks). Deadlock: submitting a task from inside a task of the same single-worker pool waits forever.
Process-pool specifics: arguments and returns must be picklable, so lambdas, local functions, open connections, and clients fail with 'Can't pickle local object'; workers should create their own DB/HTTP clients via an initializer= function. Shutdown: use the executor as a context manager, and know shutdown(wait=True, cancel_futures=True) (3.9+) for draining on SIGTERM. Also mention executor.map's chunksize parameter, which batches items per process and can change process-pool throughput by an order of magnitude on many small tasks.
from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request
URLS = [f'https://api.example.test/health?i={i}' for i in range(20)]
def fetch(url: str) -> tuple[str, int]:
with urllib.request.urlopen(url, timeout=5) as r:
return url, r.status
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(fetch, u): u for u in URLS}
for fut in as_completed(futures, timeout=30):
url = futures[fut]
try:
_, status = fut.result() # exceptions surface HERE
except Exception as exc:
print(f'{url} failed: {exc!r}')
else:
print(f'{url} -> {status}')
# Process pool: initializer creates per-worker state (unpicklable clients)
# def _init():
# global session
# session = make_db_connection()
# with ProcessPoolExecutor(max_workers=8, initializer=_init) as pool:
# for out in pool.map(transform, rows, chunksize=200):
# ...
Q56Explain asyncio fundamentals: coroutines, the event loop, gather versus TaskGroup, and the blocking-call trap.
IntermediateAsync
Answer
async def defines a coroutine function; calling it returns a coroutine object that does nothing until awaited or scheduled, which is why a bare `fetch()` with no await produces the 'coroutine was never awaited' RuntimeWarning and no work. The event loop runs one callback/coroutine step at a time; await is the only place a coroutine can yield control, so concurrency happens exactly at await points. asyncio.run(main()) creates the loop, runs to completion, and closes it: the standard entrypoint. To run things concurrently you must create tasks: `await one(); await two()` is sequential, while wrapping them in asyncio.gather(one(), two()) or a TaskGroup runs them together. gather returns results in argument order; with return_exceptions=True failures come back as values instead of raising.
Python 3.11's asyncio.TaskGroup is now the recommended structure: `async with asyncio.TaskGroup() as tg: tg.create_task(...)` guarantees that when one task fails, siblings are cancelled and all failures surface as an ExceptionGroup handled via except*, eliminating the orphaned-task leaks gather is prone to. Timeouts: asyncio.timeout(5) (3.11+) as an async context manager, or asyncio.wait_for. The trap that fills real incident reports: any synchronous blocking call, requests.get, time.sleep, psycopg2 queries, heavy CPU loops, freezes the ENTIRE loop, so one slow handler stalls every websocket and request on that worker.
Detection: run with asyncio.run(main(), debug=True) or PYTHONASYNCIODEBUG=1, which logs callbacks exceeding loop.slow_callback_duration (default 100ms). Fixes: async-native libraries (httpx.AsyncClient, asyncpg, aiofiles), asyncio.to_thread for unavoidable blocking calls, and process pools for CPU. Also name the fire-and-forget footgun: the loop holds only weak references to tasks, so `asyncio.create_task(job())` without storing the handle can be garbage-collected mid-flight; keep a reference or use a TaskGroup.
import asyncio
import httpx
async def fetch_json(client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url, timeout=5.0)
r.raise_for_status()
return r.json()
async def main():
async with httpx.AsyncClient(base_url='https://api.example.test') as client:
# Structured concurrency (3.11+): failures cancel siblings
async with asyncio.TaskGroup() as tg:
jobs = tg.create_task(fetch_json(client, '/jobs?limit=10'))
user = tg.create_task(fetch_json(client, '/users/42'))
print(jobs.result()['count'], user.result()['name'])
# Overall deadline for a block of awaits:
try:
async with asyncio.timeout(2.0):
await fetch_json(client, '/slow-report')
except TimeoutError:
print('report timed out, degrading gracefully')
# NEVER: time.sleep(1) here (freezes the loop)
await asyncio.sleep(1) # yields to other tasks
await asyncio.to_thread(legacy_blocking_call)
asyncio.run(main())
Key Points
- Coroutines are inert until awaited; tasks are how concurrency starts
- TaskGroup (3.11+) cancels siblings on failure and raises ExceptionGroup
- One blocking call stalls the whole loop; to_thread/async drivers fix it
- Debug mode logs >100ms callbacks; keep references to created tasks
Q57Explain __repr__ versus __str__, and the __eq__/__hash__ contract you must maintain together.
IntermediateOOP
Answer
__repr__ targets developers: unambiguous, ideally eval()-round-trippable or at least identifying, shown by the REPL, inside containers, and by f'{obj!r}'. __str__ targets end users: readable output for print() and str(). If __str__ is missing, Python falls back to __repr__, so the practical rule is: always define __repr__, define __str__ only when you need a friendlier display. A container's str() calls repr() on its elements, which surprises people: printing a list of your objects uses __repr__ regardless of __str__.
In logs, prefer %r/!r formatting so empty strings and None are visible. The __eq__/__hash__ contract is the deeper half. Default behavior: object identity for both.
Overriding __eq__ sets __hash__ to None automatically, making instances unhashable; if your equal-by-value objects must live in sets or as dict keys, you must also define __hash__ such that a == b implies hash(a) == hash(b). Hash equal-contributing fields only, typically hash((self.field1, self.field2)) over the same tuple __eq__ compares; hashing fields __eq__ ignores breaks the implication, and hash collisions are fine (equality is the tiebreaker) but inequality of hashes for equal objects corrupts dict lookups silently: the object goes into one bucket and is searched in another, so `obj in myset` returns False for an 'equal' object. Second rule: hash must be immutable over the object's lifetime; hashing a mutable field means that mutating the object after inserting it into a set makes it unfindable, a legendary debugging session.
Hence value objects should be immutable, which is why @dataclass(frozen=True, eq=True) generating both methods correctly is the recommended path. Also return NotImplemented (not False) from __eq__ for foreign types so Python can try the reflected operation, and remember defining __eq__ on a subclass silently re-disables inherited hashing.
class Money:
def __init__(self, amount: int, currency: str):
self.amount, self.currency = amount, currency
def __repr__(self): # developer-facing
return f'Money(amount={self.amount!r}, currency={self.currency!r})'
def __str__(self): # user-facing
return f'{self.amount} {self.currency}'
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented # let other side try
return (self.amount, self.currency) == (other.amount, other.currency)
def __hash__(self): # SAME fields as __eq__
return hash((self.amount, self.currency))
m = Money(500, 'INR')
print(m) # 500 INR (__str__)
print([m]) # [Money(amount=500, currency='INR')] (__repr__!)
print(m == Money(500, 'INR')) # True
print({m, Money(500, 'INR')}) # one element: hash contract holds
Q58How do @property and setters work, and when do you use cached_property?
IntermediateOOP
Answer
@property turns a method into a managed attribute: callers write obj.total, not obj.total(), while your code computes on access. The full triple is @property for the getter, @total.setter for validation-on-assignment, and @total.deleter (rarely used). This is the mechanism behind Python's 'start with public attributes' philosophy: unlike Java, you never write getters preemptively, because if a plain attribute later needs logic, you convert it to a property without changing any call sites, the uniform access principle.
Idiomatic uses: derived values (full_name from first/last), validation (rejecting negative salary in the setter, keeping invalid states unrepresentable), lazy formatting, and deprecation shims that warn while forwarding to a renamed field. Anti-patterns interviewers listen for: properties that do I/O or take seconds (attribute syntax promises cheapness; a slow property hides a database query behind what reads like a field access, and a property firing a query inside a loop is a real N+1 story), properties that mutate state, and property chains that would be clearer as explicit methods. functools.cached_property computes once per instance on first access, then writes the value into the instance __dict__, so subsequent reads bypass the descriptor entirely at plain-attribute speed; it suits expensive derived data on effectively-immutable objects (parsing a document, compiling a regex set, building an index). Differences to state precisely: property recomputes every access and supports setters; cached_property computes once, supports no setter, and can be invalidated by `del obj.attr`, which forces recomputation on next access.
Because it stores into __dict__, cached_property requires instances to have one (conflicts with bare __slots__), and since 3.12 it no longer takes a class-wide lock, an actual fix for a thread-contention issue where unrelated instances serialized on first access. Under the hood both are descriptors, the natural bridge to the descriptor-protocol question.
from functools import cached_property
class Salary:
def __init__(self, base_lpa: float):
self._base = base_lpa
@property
def base(self) -> float:
return self._base
@base.setter
def base(self, value: float):
if value < 0:
raise ValueError('salary cannot be negative')
self._base = value
# invalidate dependent cache on change:
self.__dict__.pop('tax_estimate', None)
@cached_property
def tax_estimate(self) -> float:
print('computing once...')
return expensive_tax_model(self._base)
s = Salary(24)
s.base = 30 # validated assignment, same syntax as a field
_ = s.tax_estimate # computing once...
_ = s.tax_estimate # cached: no recompute
del s.tax_estimate # explicit invalidation
Key Points
- Property = computed attribute; setter validates without changing call sites
- Keep properties cheap and side-effect free; no hidden I/O
- cached_property: once per instance, stored in __dict__, del to invalidate
- Needs __dict__ (slots conflict); per-instance since 3.12 without class lock
Q59How does Python's MRO work with multiple inheritance, and what does super() actually do?
IntermediateOOP
Answer
Python linearizes a class's inheritance graph into a single Method Resolution Order using the C3 algorithm: a deterministic ordering that respects each parent's own ordering and puts children before parents. Inspect it with Cls.__mro__ or Cls.mro(). Attribute lookup walks this list left to right and stops at the first hit, which is why in class C(A, B), A's method wins a tie.
If the declared bases produce an inconsistent ordering (class C(A, B) where B is a subclass of A reversed elsewhere), class creation itself fails with 'TypeError: Cannot create a consistent method resolution order'. The part people get wrong: super() does not mean 'my parent class'. It returns a proxy that continues the lookup from the NEXT class after the current one in the INSTANCE's MRO, which depends on the runtime type, not the class where the call is written.
This is what makes cooperative multiple inheritance work: in a diamond A -> B, C -> D, if every class calls super().__init__(), constructing D runs D, B, C, A exactly once each, because each super() call advances one step along D's MRO rather than jumping to a static parent (which would run A twice). The cooperative pattern requires discipline: every method in the chain calls super() and forwards **kwargs it does not consume, so mixins compose. Practical places this bites: Django class-based views and DRF are mixin stacks where method override order IS MRO order; forgetting super().__init__() in one mixin silently skips the rest of the chain; and in __init_subclass__ or metaclass work, understanding that object terminates the chain matters.
Interview-ready summary: MRO is C3 linearization, super() is 'next in MRO', and diamonds are the test case that proves you understand the difference. Zero-argument super() works inside methods via a compiler-provided __class__ cell; the two-argument form super(Cls, obj) still exists for edge cases.
class Base:
def __init__(self, **kwargs):
print('Base')
super().__init__(**kwargs) # cooperatively ends at object
class AuditMixin(Base):
def __init__(self, *, audit=True, **kwargs):
print('Audit')
self.audit = audit
super().__init__(**kwargs)
class CacheMixin(Base):
def __init__(self, *, ttl=60, **kwargs):
print('Cache')
self.ttl = ttl
super().__init__(**kwargs)
class Service(AuditMixin, CacheMixin):
def __init__(self, name, **kwargs):
print('Service')
self.name = name
super().__init__(**kwargs)
print([c.__name__ for c in Service.__mro__])
# ['Service', 'AuditMixin', 'CacheMixin', 'Base', 'object']
svc = Service('jobs', ttl=300)
# Service -> Audit -> Cache -> Base : each class runs exactly once
Q60@classmethod versus @staticmethod versus instance methods: what does each receive, and what is the alternative-constructor pattern?
IntermediateOOP
Answer
An instance method receives the instance as its first argument (self) and is the default: use it whenever behavior reads or writes per-object state. A classmethod receives the CLASS as its first argument (cls), whether called on the class or an instance, and its killer application is alternative constructors: Model.from_json(payload), Config.from_env(), datetime.fromtimestamp() and dict.fromkeys() in the stdlib. Because the constructor receives cls rather than hard-coding the class name, subclasses inherit factories that build subclass instances: EmployeeRecord.from_csv_row(row) returns EmployeeRecord even though from_csv_row is defined on Record.
That polymorphic behavior is the exact sentence interviewers want. Classmethods also fit registry patterns and any logic touching class-level configuration. A staticmethod receives nothing implicit; it is a plain function namespaced inside the class for discoverability, appropriate when the logic belongs conceptually to the class but touches neither instance nor class state, validation helpers, unit conversions.
The honest guidance: if a staticmethod grows beyond trivial, a module-level function is usually better (easier to import, test, and mock), and some style guides skip staticmethod entirely; being able to argue that trade-off reads as experience. Mechanically, all three are descriptors: functions implement __get__ to produce bound methods (which is why accessing obj.method creates a small bound-method object, and why `f = obj.method` then f() works, the instance is baked in); classmethod and staticmethod are wrappers changing what __get__ returns. Two related details worth having ready: you cannot easily use a bare classmethod as a property (chaining @classmethod with @property was briefly allowed in 3.9 and removed in 3.13; use a metaclass property or module-level attribute instead), and abstract variants combine as @classmethod above @abstractmethod in ABCs.
import os, json
from dataclasses import dataclass
@dataclass
class DBConfig:
host: str
port: int
pool: int = 5
@classmethod
def from_env(cls, prefix: str = 'DB_') -> 'DBConfig':
# alternative constructor: subclass calling this gets subclass back
return cls(
host=os.environ[prefix + 'HOST'],
port=int(os.environ.get(prefix + 'PORT', '5432')),
)
@classmethod
def from_json(cls, raw: str) -> 'DBConfig':
return cls(**json.loads(raw))
@staticmethod
def is_valid_port(port: int) -> bool: # no self, no cls
return 0 < port < 65536
class ReplicaConfig(DBConfig):
pass
cfg = ReplicaConfig.from_json('{"host": "10.0.0.5", "port": 5433}')
print(type(cfg).__name__) # ReplicaConfig, not DBConfig: cls at work
Q61How do dataclasses work: field(), default_factory, frozen, slots, and when do you reach for pydantic instead?
IntermediateOOP
Answer
@dataclasses.dataclass generates __init__, __repr__, and __eq__ from class-level annotations, eliminating the boilerplate of attribute-assignment constructors. The knobs are where interviews go. Mutable defaults are rejected at class-creation time, `items: list = []` raises 'ValueError: mutable default', and the fix is field(default_factory=list), which calls the factory per instance, the dataclass version of the mutable-default lesson. order=True generates comparison methods from field order; frozen=True makes instances read-only (assignment raises FrozenInstanceError) and, combined with eq=True, generates __hash__ so instances can live in sets and dict keys, giving you a lightweight value object. slots=True (3.10+) generates __slots__, cutting per-instance memory significantly and speeding attribute access, with the trade-off that you cannot add undeclared attributes and multiple inheritance gets stricter. kw_only=True forces keyword construction, which keeps call sites readable and lets required fields follow defaulted ones. __post_init__ runs after the generated __init__ for validation and derived fields; with init=False fields you compute values there. dataclasses.replace(obj, status='PAID') copies-with-changes, the idiom for frozen instances; asdict()/astuple() convert recursively (beware: asdict deep-copies, which is slow on big graphs).
The pydantic boundary is the judgment part: dataclasses trust their inputs, performing zero runtime validation or coercion; pydantic BaseModel validates and coerces external, untrusted data (API payloads, env config, LLM outputs) at runtime with useful errors, at some construction cost (largely mitigated since its core moved to Rust). Standard architecture answer: pydantic at the edges where data enters the system, plain (often frozen, slotted) dataclasses for internal domain objects, attrs when you want dataclass ergonomics plus validators without pydantic's weight, and NamedTuple when you specifically want tuple behavior.
from dataclasses import dataclass, field, replace
@dataclass(frozen=True, slots=True, kw_only=True)
class JobPosting:
title: str
company: str
skills: tuple[str, ...] = () # hashable field
tags: list[str] = field(default_factory=list, hash=False, compare=False)
ctc_lpa: float = 0.0
score: float = field(init=False, default=0.0)
def __post_init__(self):
if self.ctc_lpa < 0:
raise ValueError('ctc_lpa cannot be negative')
# frozen: must bypass the blocked setattr
object.__setattr__(self, 'score', self.ctc_lpa * len(self.skills))
job = JobPosting(title='SDE-2', company='Zerodha',
skills=('python', 'kafka'), ctc_lpa=32)
print(job) # readable auto-repr
senior = replace(job, title='SDE-3', ctc_lpa=45)
print(len({job, senior})) # 2: hashable because frozen+eq and every hashed field is hashable
# job.title = 'x' # FrozenInstanceError
Key Points
- default_factory per-instance construction; mutable defaults rejected
- frozen+eq gives hashable value objects; slots=True cuts memory
- __post_init__ for validation/derived fields; replace() for copy-with-changes
- pydantic validates untrusted edges; dataclasses model trusted internals
Q62How is Python typing used in production: mypy/pyright, Optional, TypedDict, Protocol, and the 3.12 generics syntax?
IntermediateTyping
Answer
Type hints are annotations with no runtime enforcement in CPython; their value comes from static checkers (mypy, or pyright, which powers Pylance in VS Code) run in CI, where they catch a large class of None-handling and refactoring bugs before tests do. Modern syntax to demonstrate: built-in generics (list[int], dict[str, float]) since 3.9, unions as int | None since 3.10 (Optional[X] is exactly X | None and the | form is preferred), and Self (3.11) for methods returning their own class through inheritance. The 3.12 syntax (PEP 695) made generics native: `def first[T](xs: list[T]) -> T:` and `class Repo[T]: ...` replace explicit TypeVar declarations, and `type JsonDict = dict[str, object]` declares aliases.
Structural typing is the senior differentiator: TypedDict types dict shapes ({'id': int, 'name': str}) for JSON payloads without classes, with NotRequired for optional keys; Protocol defines an interface by shape, any object with a matching method satisfies it without inheriting, which is how you type 'anything with a .fetch(url) -> bytes' and unlock dependency injection with fakes in tests, no ABC registration needed. Literal['asc', 'desc'] constrains string params, and @overload types functions whose return type depends on argument types. Production practices worth naming: run mypy with strict flags incrementally (per-module ignore lists let large legacy codebases adopt gradually); py.typed marker files ship types with libraries; typing.TYPE_CHECKING guards import-only-for-types to break cycles; and runtime consumers do exist, pydantic, dataclasses, and FastAPI all read annotations at runtime, which is why the 3.14 deferred-annotations change (PEP 649) was engineered to keep introspection working via annotationlib rather than the old string-based future import.
from typing import Protocol, TypedDict, NotRequired, Literal
class JobRow(TypedDict):
id: int
title: str
ctc_lpa: NotRequired[float] # key may be absent
class Fetcher(Protocol): # structural: no inheritance needed
def fetch(self, url: str) -> bytes: ...
def load_jobs(client: Fetcher, order: Literal['asc', 'desc'] = 'asc') -> list[JobRow]:
raw = client.fetch('/jobs?order=' + order)
...
return [{'id': 1, 'title': 'SDE-2'}]
# 3.12 native generics (PEP 695): no TypeVar boilerplate
def first[T](items: list[T], default: T | None = None) -> T | None:
return items[0] if items else default
class Repo[T]:
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> 'Repo[T]':
self._items.append(item)
return self
type JsonDict = dict[str, object] # 3.12 alias statement
Q63How does structural pattern matching (match/case) work, and what is the capture-versus-constant pitfall?
IntermediateBasics
Answer
match/case (PEP 634, Python 3.10) is not a switch statement; it destructures. A match subject is tested against patterns top to bottom, the first matching case wins, and there is no fall-through. Pattern kinds: literals (case 404:), sequence patterns (case [x, y, *rest]:) which match by length and unpack, mapping patterns (case {'type': 'refund', 'amount': amt}:) which match dicts containing AT LEAST those keys, class patterns (case Point(x=0, y=y):) which use isinstance plus attribute extraction (positional class patterns need __match_args__, which dataclasses generate automatically), OR-patterns with |, guards with if, and the wildcard case _.
The pitfall that fails candidates: a bare name in a pattern is a CAPTURE, not a comparison. `case PENDING:` does not compare against your PENDING constant; it binds the subject to a new local named PENDING and matches everything, breaking every later case. CPython itself refuses to compile it when any case follows (SyntaxError: name capture 'PENDING' makes remaining patterns unreachable); as the last case it silently matches everything. The rule: to compare against a constant, it must be a dotted name, case Status.PENDING: or case http.HTTPStatus.OK:, which is one reason enums and pattern matching pair so well.
Guards evaluate after binding, so `case {'amount': amt} if amt > 50000:` reads naturally. Where it genuinely beats if/elif chains: dispatching on the shape of nested JSON events (webhooks, queue messages), AST/tree walking, and parsing command tuples; for a flat value equality check, a dict lookup or if/elif remains simpler and faster. Two more details worth volunteering: mapping patterns ignore extra keys by design (capture them with **rest), and sequence patterns match tuples and lists but deliberately NOT str/bytes, avoiding the accidental character-matching trap.
from enum import Enum
class Status(Enum):
PENDING = 'pending'
PAID = 'paid'
def handle(event: dict) -> str:
match event:
case {'type': 'payment', 'status': Status.PAID, 'amount': amt} if amt > 50_000:
return f'flag for manual review: {amt}'
case {'type': 'payment', 'status': Status.PAID}:
return 'ack'
case {'type': 'refund', 'items': [first, *rest]}:
return f'refund {first} (+{len(rest)} more)'
case {'type': str() as kind, **extra}:
return f'unknown {kind} with keys {sorted(extra)}'
case _:
return 'malformed'
# THE trap: bare name captures, it does not compare
EXPECTED = 'ping'
msg = 'pong'
match msg:
case EXPECTED: # binds msg to EXPECTED: matches ANYTHING
print('always hits!') # any case after this is a SyntaxError
# Fix: use a dotted/qualified constant, e.g. case Commands.EXPECTED
Q64How does functools.lru_cache work, and what are its production footguns (mutable args, methods, memory)?
IntermediateFunctions
Answer
functools.lru_cache(maxsize=128) memoizes a function: it builds a key from the positional and keyword arguments, stores results in an internal dict, and evicts least-recently-used entries past maxsize. functools.cache (3.9+) is lru_cache(maxsize=None), unbounded. Hits skip the function body entirely; cache_info() reports hits/misses/currsize, and cache_clear() resets. The footguns are what interviews target.
Hashable-arguments requirement: keys are built by hashing the arguments, so passing a list or dict raises TypeError: unhashable type: 'list'; convert to tuples/frozensets at the boundary. Argument-pattern sensitivity: f(1) and f(x=1) produce distinct cache keys, an easy source of duplicate entries. Identity, not equality of results: the SAME object is returned to every caller, so if the function returns a mutable list and one caller mutates it, every future caller sees the mutation; return immutables or copies.
Methods: decorating an instance method caches on self, meaning the cache keeps every instance alive for the process lifetime (a real memory leak class) and the cache is shared across instances only in the sense that self is part of the key; the standing advice is to cache a module-level helper or use functools.cached_property for per-instance one-shot computation (note cached_property stores the value in the instance __dict__, so it conflicts with __slots__ unless you include __dict__). Unbounded caches on user-controlled inputs are a denial-of-service vector: caching by URL or query string grows forever; set maxsize, and typed=True only when int/float distinction matters. Finally, lru_cache is thread-safe for consistency but does not deduplicate concurrent misses: two threads can both compute the value; if the computation must run once, add a lock. For async functions, lru_cache caches the coroutine OBJECT (await-once bug); use an async-aware cache instead.
from functools import lru_cache, cached_property
@lru_cache(maxsize=1024)
def fetch_skill_graph(skill: str) -> tuple[str, ...]:
print(f'MISS {skill}')
return tuple(expensive_lookup(skill)) # return IMMUTABLE
fetch_skill_graph('python') # MISS
fetch_skill_graph('python') # hit: body skipped
print(fetch_skill_graph.cache_info()) # hits=1 misses=1 currsize=1
# fetch_skill_graph(['python']) # TypeError: unhashable type: 'list'
class Resume:
def __init__(self, text: str):
self.text = text
@cached_property # per-instance, computed once,
def parsed(self) -> dict: # stored in instance __dict__
return heavy_parse(self.text)
# Anti-pattern: @lru_cache on a method keeps every `self` alive forever.
Key Points
- Args must be hashable; the key is built from call arguments
- Same result object returned to all callers: never return mutables
- lru_cache on methods pins instances in memory; use cached_property
- Unbounded cache on user input = memory DoS; caching async funcs breaks
Q65How do you design a custom exception hierarchy, and what do except* and ExceptionGroup (3.11) add?
IntermediateExceptions
Answer
A service should define one base exception, class AppError(Exception), with domain subclasses beneath it: PaymentError, KYCError, GatewayTimeout(PaymentError). Callers then choose their granularity: `except GatewayTimeout` for a retry path, `except PaymentError` for the payment boundary, `except AppError` at the top-level handler that maps errors to HTTP responses, while genuinely unexpected exceptions (bugs) fall through to crash loudly or hit the last-resort logger. Design rules that read as senior: carry structured context on the exception (code, retryable flag, entity ids) rather than encoding data into the message string; raise domain exceptions at boundaries with `raise PaymentError(...) from exc` so the third-party original stays attached as __cause__; never subclass BaseException (that level is reserved for KeyboardInterrupt/SystemExit, which is also why bare except is wrong); and keep the hierarchy shallow, two or three levels, because deep taxonomies rot.
ExceptionGroup solved a real gap: concurrent code can fail multiple ways AT ONCE. When two tasks in an asyncio.TaskGroup raise, the group raises ExceptionGroup('unhandled errors in a TaskGroup', [ValueError(...), TimeoutError(...)]) containing both. The new except* syntax matches INTO groups: `except* ValueError as eg:` receives a sub-group of just the ValueErrors, remaining exceptions continue propagating, and multiple except* blocks can each fire for one group, unlike regular except where exactly one handler runs. eg.exceptions holds the tuple; .split(predicate) partitions a group; nesting flattens sensibly.
Practical guidance: you mostly MEET ExceptionGroups (TaskGroups, anyio) rather than raise them; handle the categories you can recover from with except*, and re-raise the rest. Also mention exc.add_note('order_id=123') (3.11) for attaching context mid-flight, and that a regular `except ValueError` will NOT catch a ValueError inside a group, the migration gotcha that breaks pre-3.11 error handling when code moves onto TaskGroup.
import asyncio
class AppError(Exception):
retryable = False
class GatewayTimeout(AppError):
retryable = True
async def charge(i):
if i % 2:
raise GatewayTimeout(f'psp timeout order={i}')
raise ValueError(f'bad amount order={i}')
async def main():
try:
async with asyncio.TaskGroup() as tg:
for i in range(4):
tg.create_task(charge(i))
except* GatewayTimeout as eg: # sub-group of timeouts only
for e in eg.exceptions:
print('retry later:', e)
except* ValueError as eg: # BOTH handlers can run
for e in eg.exceptions:
print('reject:', e)
asyncio.run(main())
# Note: a plain `except GatewayTimeout` would NOT catch these:
# they arrive wrapped in an ExceptionGroup.
Q66Which itertools and functools tools should you know cold: chain, islice, groupby, batched, and reduce?
IntermediateStdlib
Answer
itertools is lazy iterator algebra; everything composes in constant memory. The daily set: chain(a, b) concatenates iterables without building a list, and chain.from_iterable(list_of_lists) is the canonical lazy flatten; islice(gen, 10) takes the first ten items of a generator (which cannot be sliced with [:10]), and islice(gen, 100, 200) paginates a stream; batched(iterable, n) (new in 3.12) yields tuples of n items, the tool for chunking database inserts or embedding-API calls that previously everyone hand-rolled (batched(rows, 500) then executemany per batch); takewhile/dropwhile cut streams on a predicate; count, cycle, repeat generate infinite streams (pair with islice to bound them); product, permutations, combinations enumerate; and accumulate gives running totals. The interview trap lives in groupby: it groups only CONSECUTIVE elements with equal keys, so you must sort by the same key first or you get fragmented groups, `groupby(sorted(rows, key=keyfn), key=keyfn)` is the full idiom; also each group iterator is consumed as you advance to the next group, so materialize with list(g) if you need it later.
When you just need counts or buckets by key, collections.Counter (with .most_common(n)) and defaultdict(list) are simpler than groupby, and choosing them over groupby is a judgment point. From functools: reduce folds a sequence, but idiomatic Python prefers sum, math.prod, min/max, any/all, and ''.join for the common folds; a legitimate reduce use is merging many dicts or composing functions. partial(fn, *args, **kwargs) pre-binds arguments, cleaner than a lambda for callbacks and executor.map with fixed parameters, and it preserves picklability where lambdas do not (multiprocessing again). singledispatch gives type-based function overloading, a tidy alternative to isinstance ladders in serializers. If the interviewer pushes, note pairwise (3.10) for sliding windows of two and tee for splitting one iterator into several (with the caveat that tee buffers the divergence in memory).
from itertools import chain, islice, groupby, batched, accumulate
from functools import partial, reduce
from collections import Counter
from operator import itemgetter
rows = [
{'city': 'pune', 'ctc': 18}, {'city': 'delhi', 'ctc': 25},
{'city': 'pune', 'ctc': 30}, {'city': 'delhi', 'ctc': 22},
]
# groupby REQUIRES sorted input on the same key:
by_city = sorted(rows, key=itemgetter('city'))
for city, grp in groupby(by_city, key=itemgetter('city')):
grp = list(grp) # group iterator is one-shot
print(city, sum(r['ctc'] for r in grp) / len(grp))
# 3.12 batched: chunked bulk inserts
for batch in batched(range(10), 4):
print(batch) # (0,1,2,3) (4,5,6,7) (8,9)
flat = list(chain.from_iterable([[1, 2], [3], [4, 5]]))
first_page = list(islice(iter(range(10**9)), 5)) # lazy: instant
print(list(accumulate([100, 50, 25]))) # [100, 150, 175]
send_alert = partial(notify, channel='whatsapp', priority='high')
merged = reduce(lambda a, b: a | b, [{'a': 1}, {'b': 2}], {})
print(Counter(r['city'] for r in rows).most_common(1))
Q67Why should you use pathlib over os.path, and what are the key Path operations for real file work?
IntermediateStdlib
Answer
pathlib.Path is the object-oriented replacement for the os.path string-function zoo. Construction composes with the / operator, base_dir / 'reports' / f'{date}.csv', which reads like a path and handles separators per-platform (PurePosixPath/PureWindowsPath exist for manipulating foreign paths without touching the filesystem). The properties kill most string-slicing bugs: .name (final component), .stem (name minus last suffix), .suffix ('.csv'), .suffixes (['.tar', '.gz'], the reason naive suffix logic breaks on archives), .parent, and .parents for walking up.
Common operations: .exists(), .is_file(), .is_dir(), .mkdir(parents=True, exist_ok=True) which replaces the check-then-create race with one idempotent call, .glob('*.csv') and .rglob('*.pdf') for matching, .resolve() for absolute canonical paths, .rename(), .unlink(missing_ok=True), and .stat().st_size. The convenience readers, .read_text(encoding='utf-8'), .write_text(), .read_bytes(), open-read-close in one call and are correct for small files; for large files Path.open() hands you the normal file object for streaming line iteration. Interoperability is a solved problem: open(), shutil, subprocess and virtually every library accept Path via the os.PathLike protocol (__fspath__), so 'my framework needs strings' stopped being an excuse years ago; call str(path) only at the last boundary that truly demands it.
Points that upgrade the answer: never build user-facing paths by concatenating strings from requests, and validate containment with resolve() before serving files, `target.resolve().is_relative_to(base.resolve())` (is_relative_to since 3.9) is the standard traversal defense against '../../etc/passwd' uploads; use tempfile alongside pathlib for scratch space; .walk() arrived on Path in 3.12 replacing os.walk for tree traversal; and home() / expanduser() handle ~ correctly. Sorting glob results explicitly matters too, because filesystem order is not deterministic across machines, a real flaky-pipeline story.
from pathlib import Path
base = Path('/var/data/resumes')
out = base / 'parsed' / '2026-08'
out.mkdir(parents=True, exist_ok=True) # no race, idempotent
for pdf in sorted(base.rglob('*.pdf')): # sort: fs order is not stable
target = out / pdf.with_suffix('.json').name
if target.exists() and target.stat().st_size > 0:
continue
target.write_text(parse_resume(pdf), encoding='utf-8')
archive = Path('backup.tar.gz')
print(archive.suffix) # .gz
print(archive.suffixes) # ['.tar', '.gz'] <- why .suffix alone lies
print(archive.stem) # backup.tar
# Path-traversal defense before serving a user-requested file:
def safe_open(base: Path, user_path: str):
target = (base / user_path).resolve()
if not target.is_relative_to(base.resolve()):
raise PermissionError('path escapes base directory')
return target.open('rb')
Q68How do you serialize Python objects to JSON, what breaks by default, and why is pickle dangerous?
IntermediateStdlib
Answer
json.dumps handles dict, list, str, int, float, bool, None, and nothing else. The first real payload breaks it: datetime, Decimal, UUID, set, bytes, and dataclass instances all raise TypeError: Object of type datetime is not JSON serializable. The extension points: pass default=, a function called for unknown types, returning a serializable stand-in (isoformat() for datetimes, str for Decimal and UUID, sorted lists for sets); or subclass json.JSONEncoder.
For dataclasses, dataclasses.asdict() converts recursively, then default= handles the leaf types. Deserialization is asymmetric: JSON has no datetime type, so parsing returns strings and someone must re-hydrate them, which is the practical argument for a schema layer, pydantic models serialize with model_dump_json() and parse with model_validate_json(), handling datetimes, Decimals, enums, aliases, and validation in both directions; that is the standard answer for API boundaries in 2026. Gotchas worth naming: JSON object keys must be strings, so {1: 'a'} silently becomes {'1': 'a'} and round-trips DIFFERENT from what you wrote (int keys come back as str); float('nan') and inf produce non-standard JSON that other parsers reject unless allow_nan=False is set to catch it early; json.dumps defaults to ASCII-escaping non-ASCII (ensure_ascii=False keeps Hindi text readable and smaller); sort_keys=True gives deterministic output for hashing/diffing; and for money, serialize Decimal as a string, never float, to avoid 0.30000000000000004-class corruption.
Pickle is a different animal: it serializes arbitrary object graphs including code references, and UNPICKLING EXECUTES what the stream tells it to (its opcodes can invoke any callable via __reduce__), so unpickling untrusted data is remote code execution, a finding auditors actively grep for (torch.load's default weights_only change happened for the same reason). Pickle is acceptable for trusted, same-codebase, short-lived data (multiprocessing uses it internally; that is why lambdas fail there); for anything crossing a trust or version boundary use JSON, msgpack, or protobuf.
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID, uuid4
@dataclass
class Invoice:
id: UUID
amount: Decimal
created_at: datetime
tags: set[str]
def to_jsonable(o):
match o:
case datetime(): return o.isoformat()
case Decimal(): return str(o) # NEVER float for money
case UUID(): return str(o)
case set(): return sorted(o)
raise TypeError(f'not serializable: {type(o).__name__}')
inv = Invoice(uuid4(), Decimal('4999.50'),
datetime.now(timezone.utc), {'gst', 'b2b'})
payload = json.dumps(asdict(inv), default=to_jsonable,
ensure_ascii=False, sort_keys=True)
print(payload)
print(json.dumps({1: 'a'})) # {"1": "a"} int key silently stringified
# json.loads on pickle-free JSON is safe; pickle.loads(untrusted) is RCE.
Key Points
- default= hook or JSONEncoder subclass for datetime/Decimal/UUID/set
- Int keys stringify silently; NaN/inf output breaks strict parsers
- pydantic models are the 2026 boundary-serialization default
- Unpickling untrusted bytes executes code: JSON/msgpack across trust lines
Q69Why is pytest the default test framework, and how do fixtures, parametrize, and conftest.py actually work?
IntermediateTesting
Answer
pytest won because tests are plain functions with plain assert statements: assertion rewriting hooks the import system to re-compile asserts so a failing `assert response.status == 200` prints both sides and diffs of containers, no assertEqual zoo needed. Discovery is convention-based (test_*.py files, test_* functions). Fixtures are its dependency-injection system: declare `def test_search(client):` and pytest finds a fixture named client, runs it, and injects the result; fixtures compose (client can depend on db), and yield fixtures put teardown after the yield, guaranteed to run.
Scope controls cost: function (default, fresh per test), class, module, and session, a session-scoped Postgres container via testcontainers starts once for the whole run, while each test gets a function-scoped transaction rolled back for isolation; that layering is the canonical fast-database-testing answer. conftest.py makes fixtures available to all tests in its directory tree without imports, and autouse=True applies one everywhere (freezing time, seeding RNG). @pytest.mark.parametrize multiplies one test body across many cases with readable ids, turning ten copy-pasted GST/valid-email tests into a table; stacking parametrize decorators produces the cross-product, and pytest.param(..., marks=pytest.mark.xfail) marks a known-broken case inline. Built-in fixtures worth naming: tmp_path (per-test pathlib directory), monkeypatch (attribute/env patching auto-undone), capsys (captured stdout), caplog (captured log records). Markers organize suites: @pytest.mark.slow plus `-m 'not slow'` keeps the default run fast; pytest.raises(ValueError, match='negative') asserts exceptions precisely.
The plugin ecosystem seals it: pytest-asyncio for async tests, pytest-cov for coverage gates, pytest-xdist for parallel `-n auto`, pytest-randomly to expose inter-test coupling. Daily flags: -x stop at first failure, -k 'search and not slow' select by expression, --lf rerun last failures, -q quiet. unittest remains fine for stdlib-only constraints, and pytest runs unittest suites, easing migration.
import pytest
@pytest.fixture(scope='session')
def db():
conn = start_test_db() # once per test run
yield conn
conn.stop() # teardown after ALL tests
@pytest.fixture
def tx(db):
t = db.begin() # per-test isolation
yield t
t.rollback()
@pytest.mark.parametrize('gstin,ok', [
('27AAPFU0939F1ZV', True),
('27AAPFU0939F1Z', False), # too short
('', False),
pytest.param('lowercase27z', False, id='lowercase'),
])
def test_gstin_validation(gstin, ok):
assert is_valid_gstin(gstin) is ok
def test_negative_salary_rejected(tx):
with pytest.raises(ValueError, match='negative'):
create_offer(tx, ctc_lpa=-5)
def test_parser_writes_report(tmp_path, monkeypatch):
monkeypatch.setenv('REPORT_DIR', str(tmp_path))
run_parser('resume.pdf')
assert (tmp_path / 'report.json').exists()
Key Points
- Assertion rewriting gives rich diffs from plain assert
- Fixtures = DI with scopes; yield fixtures guarantee teardown
- Session-scoped container + per-test rollback = fast DB tests
- parametrize turns copy-paste into case tables; conftest shares fixtures
Q70How does unittest.mock work, and why is 'patch where it's used, not where it's defined' the rule?
IntermediateTesting
Answer
unittest.mock provides Mock and MagicMock objects that accept any call and attribute access, recording everything for later assertion: mock.assert_called_once_with(url, timeout=5), mock.call_args_list, mock.return_value, and mock.side_effect (raise an exception, return successive values from a list, or run a function). MagicMock additionally implements magic methods so it survives len(), iteration, and context managers. patch('pkg.module.name') temporarily replaces an attribute for the duration of a test (decorator, context manager, or pytest's mocker fixture from pytest-mock) and restores it afterwards, even on failure. The rule that decides this question: patch the name WHERE IT IS LOOKED UP, not where it is defined.
If services/notify.py does `from sms_client import send_sms`, that from-import copied a reference into the notify module's namespace at import time; patching 'sms_client.send_sms' rebinds the original module's name, but notify still holds its own copied reference, so the real function runs and your test either fails mysteriously or, worse, sends real SMS. The working target is 'services.notify.send_sms'. (Had notify used `import sms_client` and called sms_client.send_sms(), patching the source module would work, because lookup happens through the module attribute at call time.)
Second discipline: autospec=True (or create_autospec) shapes the mock to the real object's signature, so calling it with wrong arguments raises TypeError instead of silently recording nonsense; without it, refactoring a function's parameters leaves green tests asserting calls that can no longer happen, the classic false-positive suite. Related tools: patch.dict for os.environ, patch.object for attributes on a class, seal() to prevent accidental new attributes, and AsyncMock (3.8+) whose return is awaitable, required when mocking async functions (a plain Mock returns an unawaitable and fails with 'object Mock can't be used in await expression'). Strategic close: mock at architectural boundaries (HTTP, queues, clocks, randomness), prefer fakes or dependency injection for your own logic, and treat a test file full of deep patch chains as a design smell pointing at hidden coupling.
# services/notify.py
# from sms_client import send_sms
# def alert(user, msg, retries=2):
# for attempt in range(retries):
# try:
# return send_sms(user.phone, msg)
# except TimeoutError:
# if attempt == retries - 1:
# raise
from unittest.mock import patch, AsyncMock, create_autospec
# WRONG: patches the origin; notify keeps its copied reference
# @patch('sms_client.send_sms')
@patch('services.notify.send_sms', autospec=True) # where it's USED
def test_alert_sends_sms(mock_send):
mock_send.return_value = {'status': 'queued'}
result = alert(user, 'OTP is 4242')
mock_send.assert_called_once_with(user.phone, 'OTP is 4242')
assert result['status'] == 'queued'
@patch('services.notify.send_sms', autospec=True)
def test_alert_retries_on_timeout(mock_send):
mock_send.side_effect = [TimeoutError(), {'status': 'queued'}]
assert alert(user, 'hi')['status'] == 'queued'
assert mock_send.call_count == 2
async def test_async_gateway():
gateway = AsyncMock(return_value={'ok': True})
assert (await gateway('/charge'))['ok']
Q71How do you set up the logging module properly for a service, and why is logger.info(f'...') an anti-pattern?
IntermediateProduction
Answer
The logging module separates four concerns: Loggers (named entry points, hierarchical by dots: 'app.payments' is a child of 'app'), Handlers (where records go: StreamHandler, RotatingFileHandler, SysLogHandler), Formatters (how they render), and Filters. The standard setup: each module creates `logger = logging.getLogger(__name__)`, so the logger tree mirrors the package tree, and configuration happens exactly once at the entrypoint, via logging.config.dictConfig, never scattered basicConfig calls in libraries (a library must not configure logging at all, only emit; that etiquette question comes up often). Levels gate cost twice: the logger's level decides record creation, the handler's decides emission, so you can keep DEBUG in a file while sending WARNING+ to stderr.
Records propagate up the tree to the root's handlers unless propagate=False, which is the mechanism behind both 'configure the root once' and the double-logging bug where a record prints twice because a child logger got its own handler AND propagated. The f-string point: logger.info(f'user {user_id} logged in') formats the string EVERY call even when INFO is disabled, and it can be surprisingly expensive when the interpolated object has a heavy __repr__ (an ORM row triggering lazy loads). logger.info('user %s logged in', user_id) defers formatting until a handler actually emits, and keeps the message template constant, which log aggregators (Sentry, ELK, SigNoz) use to group events; f-strings explode one template into millions of unique messages, wrecking grouping and sampling. ruff's G004 rule flags f-strings in log calls for exactly this. Production specifics to volunteer: logger.exception('failed to charge') inside an except block records the traceback; structured JSON logs via a JSON formatter (or structlog) with request_id/user_id fields make logs queryable; and never log secrets, mask tokens at the formatter/filter layer, an audit item at every fintech.
import logging, logging.config
logging.config.dictConfig({
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {'format': '{"ts":"%(asctime)s","lvl":"%(levelname)s",'
'"logger":"%(name)s","msg":"%(message)s"}'},
},
'handlers': {
'stdout': {'class': 'logging.StreamHandler', 'formatter': 'json'},
},
'loggers': {
'app': {'level': 'INFO', 'handlers': ['stdout'], 'propagate': False},
'httpx': {'level': 'WARNING'}, # silence chatty dependency
},
})
logger = logging.getLogger('app.payments')
user_id = 42
logger.info('charge initiated for user %s', user_id) # lazy, groupable
# logger.info(f'charge initiated for {user_id}') # G004 anti-pattern
try:
1 / 0
except ZeroDivisionError:
logger.exception('charge failed for user %s', user_id) # + traceback
Key Points
- getLogger(__name__) per module; dictConfig once at the entrypoint
- Libraries emit, applications configure; propagation causes double logs
- %s-style args defer formatting and keep templates groupable (ruff G004)
- logger.exception captures tracebacks; mask secrets in formatters
Q72How does modern Python packaging work: pyproject.toml, build backends, editable installs, and the src layout?
IntermediateTooling
Answer
pyproject.toml is the single configuration file for modern Python projects (PEP 517/518/621). The [project] table declares name, version, dependencies, optional-dependencies (extras like `pip install mypkg[dev]`), requires-python, and entry points ([project.scripts] maps a CLI command to a function, replacing setup.py console_scripts). The [build-system] table names a build backend, hatchling, setuptools, flit-core, or uv_build, and pip/uv invoke it through a standard hook interface, which is what finally decoupled packaging from setuptools' monopoly. setup.py survives only for projects compiling native extensions or as a legacy shim; writing a new pure-Python project with setup.py in 2026 is a dated signal.
Distribution formats: an sdist is the source archive; a wheel (.whl) is a prebuilt zip that installs by unpacking, no code execution, and platform-specific wheels (manylinux tags) are why numpy installs in seconds instead of compiling C. Editable installs, `pip install -e .` or `uv pip install -e .` (PEP 660), put a live link to your source into site-packages so edits apply without reinstalling: the default development mode. The src layout, putting your package at src/mypkg/ instead of mypkg/ at the repo root, exists to fix an import trap: with a flat layout, running tests from the repo root imports the local directory rather than the installed package, hiding packaging bugs (missing files in the wheel, absent __init__.py) until release; src layout forces tests to run against the installed (editable) package, surfacing those errors early.
That reasoning, not fashion, is the interview answer. Round out with tool configuration living in the same file ([tool.ruff], [tool.pytest.ini_options], [tool.mypy]), version single-sourcing via dynamic = ['version'], and publishing flow: `uv build` (or python -m build) then twine/`uv publish` to PyPI with a Trusted Publisher (OIDC from GitHub Actions) instead of long-lived API tokens.
# pyproject.toml
[project]
name = 'resume-parser'
version = '1.4.0'
requires-python = '>=3.11'
dependencies = [
'httpx>=0.27',
'pydantic>=2.7',
]
[project.optional-dependencies]
dev = ['pytest>=8', 'ruff', 'mypy']
[project.scripts]
parse-resume = 'resume_parser.cli:main' # installs a CLI command
[build-system]
requires = ['hatchling']
build-backend = 'hatchling.build'
[tool.ruff]
line-length = 100
# Layout:
# pyproject.toml
# src/resume_parser/__init__.py
# tests/test_cli.py
# Dev setup: uv pip install -e '.[dev]'
Key Points
- PEP 621 [project] table + pluggable build backends replaced setup.py
- Wheels install without executing code; sdists are the source of truth
- pip install -e . links source live into site-packages (PEP 660)
- src layout forces tests to hit the installed package, catching packaging bugs
Q73How do you make Python builds reproducible: lockfiles, hash pinning, and interpreter version control?
IntermediateTooling
Answer
Reproducibility means the same inputs produce the same environment on every machine and every CI run. The layers, bottom up. Interpreter: pin the exact Python version, via a .python-version file that uv and pyenv both read, and match it in the Docker base image; minor-version drift (3.13 vs 3.14) changes stdlib behavior and wheel availability.
Direct dependencies: declared in pyproject.toml with honest ranges (pydantic>=2.7,<3). Full graph: a lockfile pins every transitive package to an exact version plus artifact hashes, uv.lock (cross-platform, records wheels per platform), poetry.lock, or a pip-tools-compiled requirements.txt generated by `uv pip compile pyproject.toml -o requirements.txt --generate-hashes`. Hash pinning matters for supply-chain security: pip installs with --require-hashes refuse any artifact whose SHA256 differs from the lock, defeating tag-replacement and some typosquat/mirror attacks; after the string of PyPI malware incidents this is a standard control in regulated environments.
CI/CD rules: install from the lock in frozen mode, `uv sync --locked` fails the build if pyproject.toml and uv.lock disagree, rather than silently re-resolving (`--frozen` installs from the lock without that check), so a teammate who added a dependency without locking breaks CI loudly. Docker: copy pyproject.toml and uv.lock before the source code so the dependency layer caches independently of code changes, run `uv sync --locked --no-dev`, and prefer slim base images. Upgrades become deliberate: `uv lock --upgrade-package httpx` regenerates the lock reviewably, and tools like Renovate/Dependabot open PRs against the lockfile.
Common failure stories worth telling: an unpinned transitive dependency releasing a breaking version and failing only fresh installs ('works on my machine, broke in prod deploy'), and platform drift where a Mac-generated requirements freeze lacks Linux-only wheels; cross-platform lockfiles solved that class. If asked about libraries versus applications: applications commit lockfiles; libraries declare ranges and test against a matrix, they must not lock their consumers.
# .python-version
3.14
# Add + lock (uv workflow)
uv add 'httpx>=0.27,<1'
uv lock # rewrites uv.lock with hashes
uv sync --locked # CI: fail if lock is stale
uv lock --upgrade-package httpx # deliberate, reviewable upgrade
# pip-tools style with hash enforcement
uv pip compile pyproject.toml -o requirements.txt --generate-hashes
pip install --require-hashes -r requirements.txt
# Dockerfile (layer-cached deps, frozen install)
# FROM python:3.14-slim
# COPY pyproject.toml uv.lock ./
# RUN pip install uv && uv sync --locked --no-dev
# COPY src/ ./src/
Q74How do you profile Python code: cProfile, timeit, tracemalloc, and py-spy on a live process?
IntermediatePerformance
Answer
Rule zero: measure before optimizing, and measure the right dimension (CPU time, wall time, memory, or allocations are different investigations). Micro-benchmarks: timeit runs a snippet millions of times with GC disabled, `python -m timeit -s 'setup' 'stmt'` or timeit.repeat in code; take the MINIMUM of repeats (noise only adds time), and distrust sub-microsecond differences. Function-level CPU profiling: cProfile, `python -m cProfile -s cumtime app.py` or profiling a single call with cProfile.Profile() as a context manager (supported since 3.8), then reading tottime (time inside the function itself) versus cumtime (including callees), a distinction interviewers explicitly test; dump with -o out.prof and visualize with snakeviz or flameprof. cProfile's overhead distorts hot tight loops, and it only sees the profiled process.
For production: py-spy, a sampling profiler in Rust that attaches to a RUNNING process by PID with negligible overhead and no code changes, `py-spy top --pid 12345` for a live view, `py-spy record -o profile.svg --pid 12345` for a flamegraph, and `py-spy dump` to print every thread's current stack, which is the fastest way to diagnose a hung worker (everyone blocked on a lock or a socket). Memory: tracemalloc (stdlib) snapshots allocations with tracebacks, start it early, take snapshots, and compare_to('lineno') shows which lines grew between snapshots, the standard leak-hunting loop; objgraph complements it by showing what REFERENCES a leaking object. Line-level detail: line_profiler (@profile + kernprof) when cProfile points at one fat function.
Modern additions worth naming: Python 3.15's profiling.sampling module (Tachyon, PEP 799) is a built-in low-overhead sampling profiler that can attach to a running process much like py-spy; Scalene reports CPU/GPU/memory per line and separates Python from native time, and perf integration (3.12+, python -X perf) exposes Python frames to Linux perf. Close with the workflow: profile, find the one hot spot (it is almost always I/O, serialization, or an accidentally quadratic loop), fix, re-measure, and only then consider NumPy vectorization, caching, or native extensions.
# Micro-benchmark two idioms
python -m timeit -s "xs=list(range(1000))" "[x*2 for x in xs]"
python -m timeit -s "xs=list(range(1000))" "list(map(lambda x: x*2, xs))"
# CPU profile a script to a file (-s is ignored when -o is given; sort later)
python -m cProfile -o out.prof pipeline.py
python -c "import pstats; pstats.Stats('out.prof').sort_stats('cumtime').print_stats(15)"
# visualize: snakeviz out.prof
# Attach to a LIVE stuck worker (no restart, no code change)
py-spy dump --pid 12345 # every thread's stack right now
py-spy record -o flame.svg --pid 12345 --duration 30
# Memory growth between two points (in code):
# import tracemalloc
# tracemalloc.start()
# snap1 = tracemalloc.take_snapshot()
# ... suspicious work ...
# snap2 = tracemalloc.take_snapshot()
# for stat in snap2.compare_to(snap1, 'lineno')[:10]:
# print(stat)
Q75What changed in Python 3.12, 3.13, 3.14 and 3.15 that an interviewer expects you to know?
IntermediateVersions
Answer
Being current is cheap signal, and this question is now common screening material. Python 3.12 (Oct 2023): PEP 695 native generics syntax (class Stack[T]: and def first[T](xs: list[T]) -> T:, plus the `type Alias = ...` statement) replacing TypeVar boilerplate; PEP 701 formalized f-strings (nested same quotes, multiline expressions); itertools.batched() for chunking; per-interpreter GIL landed at the C-API level (PEP 684); comprehension inlining sped comprehensions up; and distutils was removed after deprecation. Python 3.13 (Oct 2024): the headline pair of experimental builds, free-threaded CPython (PEP 703, the python3.13t build with the GIL disabled) and a basic JIT compiler (PEP 744, off by default); a much better interactive REPL with multiline editing and colored tracebacks (the first thing you notice day one); improved error messages; and the PEP 594 'dead batteries' removal deleted 19 legacy modules including cgi, telnetlib and crypt (smtpd and asyncore had already gone in 3.12), which is exactly the kind of upgrade-blocking detail teams hit in real migrations.
Python 3.14 (Oct 2025): free-threading graduated from experimental to officially supported (PEP 779), though it remains a separate build; PEP 734 exposed multiple interpreters in the stdlib via concurrent.interpreters; PEP 750 template strings (t-strings) for safely templating SQL/HTML; PEP 649/749 deferred annotation evaluation, largely ending the `from __future__ import annotations` era; and a new zstd compression module (PEP 784). Python 3.15 (October 2026): PEP 810 explicit lazy imports (the lazy import soft keyword) for faster startup; PEP 686 made UTF-8 the default encoding everywhere; PEP 814 frozendict and PEP 661 sentinel built-ins; PEP 798 unpacking inside comprehensions; a significantly upgraded (still experimental) JIT; PEP 799's new profiling package with the Tachyon sampling profiler; and PEP 803's stable ABI for free-threaded builds, with the official macOS installers now shipping free-threading support. Sensible framing for interviews: 3.12 and 3.13 are already in security-fix-only status on python.org, many production fleets still run them, and 3.14 and 3.15 are the bugfix releases, so answer 'free-threading is supported as a separate build and the ecosystem is catching up' rather than 'the GIL is gone'.
Key Points
- 3.12: PEP 695 generics syntax, PEP 701 f-strings, itertools.batched, distutils removed
- 3.13: experimental free-threaded build + JIT, new REPL, 19 dead-battery modules removed
- 3.14: free-threading officially supported (PEP 779), t-strings (PEP 750), concurrent.interpreters (PEP 734), deferred annotations (PEP 649)
- 3.15: lazy imports (PEP 810), UTF-8 default (PEP 686), frozendict (PEP 814), upgraded JIT, stable free-threaded ABI (PEP 803)
- Say 'GIL-free is a separate supported build', never 'the GIL is gone'
Q76How do NumPy arrays differ from Python lists, and what is broadcasting?
IntermediatePandas and NumPy
Answer
A Python list is an array of pointers to arbitrary objects; a NumPy ndarray is a contiguous block of fixed-type elements (its dtype) with a shape and strides. That layout is why NumPy is fast: arithmetic runs in compiled C loops over raw memory instead of dispatching Python operations per element, and a float64 array uses roughly a quarter of the memory of a list of Python floats (8 bytes per element versus a pointer plus a 24-byte float object). The cost is homogeneity, fixed size and a learning curve around shapes.
Vectorisation means expressing a loop as a whole-array operation: prices * 1.18 multiplies a million elements without a Python-level loop. Broadcasting is the rule set that lets arrays of different shapes combine: NumPy aligns trailing dimensions, and a dimension of size 1 (or a missing dimension) is stretched to match. A (3, 4) matrix plus a (4,) vector adds the vector to each row; a (3, 1) column plus a (1, 4) row gives a (3, 4) outer sum. Shapes that are neither equal nor 1 raise a ValueError.
Interviewers for data roles follow with views versus copies (slicing an array returns a view that shares memory; fancy indexing copies) and with the GIL question: NumPy releases the GIL inside heavy operations, so it can use threads where pure Python cannot.
import numpy as np
import sys, time
prices = np.array([100.0, 250.0, 80.0])
print(prices * 1.18) # vectorised: [118. 295. 94.4]
print(prices.dtype, prices.shape) # float64 (3,)
m = np.arange(12).reshape(3, 4) # (3, 4)
row = np.array([10, 20, 30, 40]) # (4,) broadcast to each row
col = np.array([[1], [2], [3]]) # (3, 1) broadcast to each column
print(m + row)
print(m + col)
try:
m + np.array([1, 2, 3]) # (3,) does not align with 4
except ValueError as e:
print('broadcast error:', e)
view = m[0] # slicing returns a view
view[0] = 99
print(m[0, 0]) # 99: the original changed
big = list(range(1_000_000))
arr = np.arange(1_000_000)
t = time.perf_counter(); _ = [x * 2 for x in big]; t_list = time.perf_counter() - t
t = time.perf_counter(); _ = arr * 2; t_np = time.perf_counter() - t
print(f'list {t_list:.4f}s numpy {t_np:.4f}s')
Key Points
- ndarray: contiguous, single dtype, shape and strides; list: pointers to objects
- Vectorise instead of looping; compiled loops and less memory
- Broadcasting aligns trailing dimensions; size-1 dimensions stretch
- Slices are views (shared memory); fancy indexing copies
Q77What is a pandas DataFrame, and what is the difference between loc and iloc?
IntermediatePandas and NumPy
Answer
A DataFrame is a two-dimensional, labelled table: each column is a Series with one dtype, rows are identified by an Index, and the whole thing sits on NumPy (or Arrow-backed) arrays. It is the standard in-memory structure for cleaning, filtering, grouping and joining data in Python, and the first thing a data analyst or data engineer interviewer asks you to manipulate.
loc selects by label: df.loc['r2', 'salary'] uses the index label and the column name, and slices with loc are inclusive of both ends because labels have no natural 'one past the end'. iloc selects by integer position: df.iloc[1, 2] is the second row, third column, and slices are half-open like normal Python. The confusion arises when the index is itself integers but not 0..n-1, for example after filtering: df.loc[5] means the row labelled 5, df.iloc[5] means the sixth row.
Two more rules interviewers check: df[col] selects a column, df[mask] filters rows with a boolean Series, and chained indexing such as df[df.x > 0]['y'] = 1 never updates the original: under Copy-on-Write (the only mode since pandas 3.0) it writes to a temporary and pandas raises a ChainedAssignmentError warning, while older versions gave the SettingWithCopyWarning. Use df.loc[mask, 'y'] = 1.
import pandas as pd
df = pd.DataFrame(
{'name': ['Asha', 'Bilal', 'Chen', 'Dev'],
'city': ['Pune', 'Noida', 'Pune', 'Chennai'],
'salary': [9.5, 7.0, 12.0, 6.5]},
index=['r1', 'r2', 'r3', 'r4'])
print(df.loc['r2', 'salary']) # 7.0 by label
print(df.iloc[1, 2]) # 7.0 by position
print(df.loc['r1':'r2', 'name'].tolist()) # ['Asha', 'Bilal'] inclusive
print(df.iloc[0:2]['name'].tolist()) # ['Asha', 'Bilal'] half-open
pune = df[df['city'] == 'Pune'] # boolean mask filter
print(pune.index.tolist()) # ['r1', 'r3']
df.loc[df['salary'] > 9, 'band'] = 'A' # safe assignment with loc
df['band'] = df['band'].fillna('B')
print(df)
numeric = df.reset_index(drop=True) # index is now 0..3
print(numeric.loc[1, 'name'], numeric.iloc[1]['name']) # same row only because labels == positions
Key Points
- DataFrame: labelled 2-D table of Series columns over NumPy arrays
- loc = labels, inclusive slices; iloc = positions, half-open slices
- Integer index labels and positions differ after filtering or sorting
- Assign with df.loc[mask, col] = value, never chained indexing
Q78How do groupby, merge and read_csv work in pandas, and what are the common mistakes?
IntermediatePandas and NumPy
Answer
read_csv is where most data problems start: specify dtype for ID columns so leading zeros survive, parse_dates for timestamps, and usecols to avoid loading what you will drop. For large files, chunksize returns an iterator of DataFrames so you can aggregate without loading everything.
groupby follows split-apply-combine: df.groupby('city')['salary'].mean() splits rows by city, applies mean to each group, and combines the results into a Series indexed by city. agg lets you compute several statistics at once, and as_index=False keeps the key as a column. The classic mistake is calling apply with a Python function when a built-in aggregation (sum, mean, size, nunique) exists; built-ins are vectorised and many times faster.
merge is SQL join: pd.merge(left, right, on='id', how='left'). Say the how options (inner, left, right, outer), check for duplicate keys on the right side (a one-to-many join silently multiplies rows), and use the validate='one_to_one' argument when you expect uniqueness. Interviewers also ask the difference between merge (by columns) and join (by index), and concat (stacking frames vertically or horizontally without matching keys).
import io
import pandas as pd
csv = io.StringIO('emp_id,city,salary,joined\n001,Pune,9.5,2024-06-01\n002,Noida,7.0,2025-01-15\n003,Pune,12.0,2023-11-20\n')
emp = pd.read_csv(csv, dtype={'emp_id': str}, parse_dates=['joined'])
print(emp.dtypes.to_dict())
print(emp.groupby('city')['salary'].mean())
print(emp.groupby('city', as_index=False).agg(avg=('salary', 'mean'), n=('emp_id', 'size')))
cities = pd.DataFrame({'city': ['Pune', 'Noida', 'Chennai'], 'state': ['MH', 'UP', 'TN']})
merged = pd.merge(emp, cities, on='city', how='left', validate='many_to_one')
print(merged[['emp_id', 'city', 'state']])
dupes = pd.DataFrame({'city': ['Pune', 'Pune'], 'office': ['Hinjewadi', 'Kharadi']})
print(len(pd.merge(emp, dupes, on='city'))) # 4: one-to-many multiplied rows
Key Points
- read_csv: dtype for IDs, parse_dates, usecols, chunksize for big files
- groupby = split-apply-combine; prefer built-in aggregations over apply
- merge is a SQL join on columns; join uses the index; concat stacks
- Duplicate keys multiply rows; use validate= to catch it
Q79How do you handle missing values in pandas, and why is apply slower than a vectorised expression?
IntermediatePandas and NumPy
Answer
Missing data appears as NaN for floats, NaT for datetimes, and None or pd.NA for object and nullable dtypes. Detect it with isna() and notna(), count it with df.isna().sum(), drop rows or columns with dropna(how=, subset=, thresh=), and fill with fillna using a constant, the column mean or median, or ffill and bfill for time series. Never compare to NaN with ==, because NaN is not equal to itself; that is one of the most common bugs in fresher pandas code.
Two consequences interviewers check: an integer column with a missing value is upcast to float unless you use the nullable Int64 dtype, and aggregations skip NaN by default (skipna=True), so a mean over a mostly empty column can look healthier than it is.
apply with a Python function runs that function once per row or element in the interpreter, so it is a Python loop in disguise. A vectorised expression (df.a * df.b, np.where, df.col.str methods, pd.cut) runs in C over whole columns. The usual 10x to 100x difference is the thing to say, then show that you would reach for apply only when no vectorised equivalent exists.
import numpy as np
import pandas as pd
import time
df = pd.DataFrame({'age': [25, None, 31, 42], 'city': ['Pune', None, 'Noida', 'Pune']})
print(df.dtypes['age']) # float64: int upcast because of NaN
print(df.isna().sum().to_dict()) # {'age': 1, 'city': 1}
print(np.nan == np.nan) # False: use isna()
filled = df.copy()
filled['age'] = filled['age'].fillna(filled['age'].median())
filled['city'] = filled['city'].fillna('unknown')
print(filled)
print(df.dropna(subset=['age']).shape) # (3, 2)
print(pd.array([1, None, 3], dtype='Int64')) # nullable integer dtype
n = 300_000
big = pd.DataFrame({'a': np.random.rand(n), 'b': np.random.rand(n)})
t = time.perf_counter(); _ = big.apply(lambda r: r['a'] * r['b'], axis=1); t_apply = time.perf_counter() - t
t = time.perf_counter(); _ = big['a'] * big['b']; t_vec = time.perf_counter() - t
print(f'apply {t_apply:.3f}s vectorised {t_vec:.4f}s')
Key Points
- isna/notna, dropna, fillna (constant, median, ffill); NaN != NaN
- Missing ints upcast to float; use Int64 for nullable integers
- Aggregations skip NaN by default; check counts before trusting means
- apply(axis=1) is a Python loop; vectorised column math runs in C
Q80Flask vs Django vs FastAPI: how do they differ, and which one do you pick for a given project?
IntermediateDjango and Flask
Answer
Django is the batteries-included framework: ORM with migrations, admin site, authentication, forms, sessions, a template engine and a security baseline (CSRF, SQL injection protection, clickjacking headers) out of the box. It is the default for content-heavy products and internal tools where you want conventions and an admin on day one. Django REST Framework adds serialisers and viewsets for APIs.
Flask is a micro-framework: routing, request and response objects, Jinja2 templates, and nothing else by default. You choose the ORM (usually SQLAlchemy), auth and validation yourself, which suits small services, prototypes and teams that want full control. FastAPI is the modern API-first choice: async by default on ASGI, request validation and OpenAPI docs generated from type hints and pydantic models, and dependency injection; it is a common choice at Indian startups for new microservices and ML model serving.
Interview answer shape: name the project type, then the framework, then the trade-off. 'Admin-heavy SaaS with relational data: Django. Thin service wrapping a model or a few endpoints: FastAPI. Legacy or tiny internal tool: Flask.' Then mention that all three run on the same production stack (gunicorn or uvicorn behind nginx) and that choosing async only pays off for I/O-bound endpoints.
# The same endpoint in Flask and FastAPI. Django needs a project; see the Django questions.
# Flask (sync, WSGI)
from flask import Flask, jsonify, request
flask_app = Flask(__name__)
@flask_app.get('/jobs/<int:job_id>')
def get_job(job_id):
return jsonify({'id': job_id, 'q': request.args.get('q')})
with flask_app.test_client() as client:
print(client.get('/jobs/7?q=python').get_json())
# FastAPI (async, ASGI, validation from type hints)
# from fastapi import FastAPI
# from pydantic import BaseModel
#
# app = FastAPI()
#
# class Job(BaseModel):
# title: str
# ctc_lpa: float
#
# @app.post('/jobs')
# async def create_job(job: Job): # invalid body -> 422 automatically
# return {'created': job.title}
Key Points
- Django: ORM, admin, auth, migrations, conventions; best for relational, admin-heavy products
- Flask: minimal core, you pick the pieces; small services and prototypes
- FastAPI: ASGI, async, pydantic validation, auto OpenAPI; APIs and model serving
- Answer by project type, then framework, then trade-off
Q81What is the difference between WSGI and ASGI, and how do gunicorn and uvicorn fit in?
IntermediateDjango and Flask
Answer
WSGI (PEP 3333) is the synchronous interface between a Python web server and a web application: the server calls app(environ, start_response) once per request, on one thread or process, and gets back an iterable body. Flask and classic Django are WSGI apps. It is simple and battle-tested, but a handler that waits on a database or an HTTP call blocks its worker for the duration, so concurrency comes from running many workers or threads.
ASGI is the asynchronous successor: the application is an async callable that receives scope, receive and send, which lets one worker juggle thousands of in-flight requests with async/await, and also supports WebSockets, server-sent events and long-lived connections that WSGI cannot express. FastAPI (via Starlette), Django's async views and Django Channels are ASGI.
gunicorn is a WSGI server that manages a pool of worker processes; uvicorn is an ASGI server built on uvloop and httptools. The common production pattern is gunicorn as the process manager with uvicorn workers (gunicorn -k uvicorn.workers.UvicornWorker) behind nginx. The interview trap: ASGI only helps if your code awaits; a synchronous database driver inside an async view blocks the event loop and performs worse than WSGI.
# Minimal WSGI app: synchronous callable
def wsgi_app(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/plain')])
return [b'hello from WSGI']
# Minimal ASGI app: async callable with scope/receive/send
async def asgi_app(scope, receive, send):
assert scope['type'] == 'http'
await send({'type': 'http.response.start', 'status': 200,
'headers': [(b'content-type', b'text/plain')]})
await send({'type': 'http.response.body', 'body': b'hello from ASGI'})
# Drive both without a server, to show the contracts
from io import BytesIO
print(b''.join(wsgi_app({'REQUEST_METHOD': 'GET'}, lambda s, h: None)))
import asyncio
sent = []
async def fake_send(msg):
sent.append(msg)
asyncio.run(asgi_app({'type': 'http'}, None, fake_send))
print(sent[1]['body'])
# Production:
# gunicorn app:wsgi_app --workers 4 (WSGI)
# gunicorn app:asgi_app -k uvicorn.workers.UvicornWorker (ASGI)
Key Points
- WSGI: sync app(environ, start_response); Flask, classic Django
- ASGI: async app(scope, receive, send); FastAPI, Django async views, WebSockets
- gunicorn manages processes (WSGI); uvicorn serves ASGI; combine them in production
- Blocking calls inside async handlers stall the whole event loop
Q82Explain Django's MVT pattern and the request lifecycle: URLconf, middleware, views, templates.
IntermediateDjango and Flask
Answer
Django calls its architecture MVT: Model (the ORM classes mapped to tables), View (a function or class that receives an HttpRequest and returns an HttpResponse), and Template (HTML with Django template language). It is MVC with the controller role split between Django's URL dispatcher and the view, which is why 'where is the controller?' is a common question: the framework itself is the controller.
Request lifecycle: the WSGI or ASGI server hands the request to Django's handler; the request passes down the MIDDLEWARE list in order (security headers, sessions, CSRF, authentication), each middleware able to short-circuit with a response; the URLconf (urls.py) matches the path and calls the view with captured parameters; the view queries models, renders a template or builds JSON, and returns a response; the response then passes back up through the middleware in reverse order. Exceptions are converted to error responses by the handler, and process_exception hooks run first.
Interviewers follow with: what does a migration do (records model changes as Python files and applies them to the schema), function-based versus class-based views, and where to put business logic (models and services, not views or templates).
# A complete Django app in one file, runnable without a project (for the lifecycle demo).
import django
from django.conf import settings
settings.configure(
DEBUG=True, SECRET_KEY='demo', ROOT_URLCONF=__name__, ALLOWED_HOSTS=['*'],
MIDDLEWARE=['django.middleware.common.CommonMiddleware', __name__ + '.timing'],
)
django.setup()
import time
from django.http import JsonResponse
from django.urls import path
def timing(get_response): # middleware: runs around every view
def middleware(request):
start = time.perf_counter()
response = get_response(request) # down to the view and back
response['X-Elapsed-ms'] = f'{(time.perf_counter() - start) * 1000:.2f}'
return response
return middleware
def job_detail(request, job_id): # view: request in, response out
return JsonResponse({'id': job_id, 'title': 'Python Developer'})
urlpatterns = [path('jobs/<int:job_id>/', job_detail)] # URLconf
from django.test import Client
resp = Client().get('/jobs/42/')
print(resp.status_code, resp.json(), 'X-Elapsed-ms' in resp.headers)
Key Points
- MVT: Model (ORM), View (request to response), Template; URLconf plus framework act as controller
- Middleware runs top-down on the request and bottom-up on the response
- URLconf matches the path and passes captured parameters to the view
- Business logic belongs in models and service functions, not views or templates
Q83What is an ORM, what is the N+1 query problem, and how do select_related and prefetch_related fix it in Django?
IntermediateDjango and Flask
Answer
An ORM (object-relational mapper) lets you work with database rows as Python objects: a Django model class is a table, an instance is a row, and QuerySet methods such as filter, exclude, order_by and annotate compile to SQL. You get portability across databases, parameterised queries (no SQL injection from string formatting), migrations and a readable codebase; you pay with a layer that can hide expensive queries.
The N+1 problem is the classic hidden cost. Loading 100 jobs with Job.objects.all() is one query, but accessing job.company.name inside the loop lazily fetches each company, so you run 1 + 100 queries. select_related('company') fixes a foreign key or one-to-one by joining in the same SQL query; prefetch_related('skills') fixes many-to-many and reverse relations by running one extra query for all related rows and stitching them in Python.
Two more things interviewers want: QuerySets are lazy and evaluated when iterated, sliced or passed to list(), and you should look at the SQL (QuerySet.query, django-debug-toolbar, or connection.queries in tests) rather than guess. Know when to drop to raw SQL or .values() for reporting queries that an ORM expresses badly.
# Runnable sketch with SQLite in memory: shows N+1 and the fix.
import django
from django.conf import settings
settings.configure(
DEBUG=True, SECRET_KEY='demo', INSTALLED_APPS=['__main__'],
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}},
)
django.setup()
from django.db import models, connection
class Company(models.Model):
name = models.CharField(max_length=50)
class Meta:
app_label = '__main__'
class Job(models.Model):
title = models.CharField(max_length=50)
company = models.ForeignKey(Company, on_delete=models.CASCADE)
class Meta:
app_label = '__main__'
with connection.schema_editor() as editor:
editor.create_model(Company)
editor.create_model(Job)
for name in ['Zerodha', 'Razorpay', 'Swiggy']:
c = Company.objects.create(name=name)
Job.objects.create(title=f'Python dev at {name}', company=c)
from django.db import reset_queries
reset_queries()
names = [job.company.name for job in Job.objects.all()] # N+1
print('N+1 queries:', len(connection.queries)) # 4
reset_queries()
names = [job.company.name for job in Job.objects.select_related('company')]
print('select_related queries:', len(connection.queries)) # 1
print(str(Job.objects.select_related('company').query)[:60])
Key Points
- ORM: classes map to tables, QuerySets compile to parameterised SQL
- N+1: one query for the list plus one per related object inside the loop
- select_related joins FK/one-to-one; prefetch_related batches M2M and reverse relations
- QuerySets are lazy; inspect .query or connection.queries instead of guessing
Q84Write a minimal Flask API with routes, request parsing, error handling and blueprints.
IntermediateDjango and Flask
Answer
A Flask app is a Flask object with routes registered by decorator. Each view returns a response: a string, a dict (auto-converted to JSON), or a tuple of body and status code. request is a context-local proxy that is only valid during a request, which is why importing it at module level works but using it outside a request raises RuntimeError.
Blueprints group routes by feature (jobs, auth, admin) so a large app is not one giant file; you register each blueprint on the app with a URL prefix. Error handling uses @app.errorhandler(404) or abort(400) for HTTP errors, and a catch-all handler that converts unexpected exceptions into a JSON 500 while logging the traceback.
Interviewers probe three things: the application factory pattern (create_app() so tests can build an app with different config), how you test (app.test_client() with no server), and deployment (never flask run in production; use gunicorn with several workers behind nginx). If they ask about g and session: g is per-request scratch storage, session is a signed cookie, and neither should hold database connections across requests without a teardown hook.
from flask import Flask, Blueprint, jsonify, request, abort
jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs')
JOBS = {1: {'id': 1, 'title': 'Python Developer', 'city': 'Pune'}}
@jobs_bp.get('/<int:job_id>')
def get_job(job_id):
job = JOBS.get(job_id)
if job is None:
abort(404)
return job # dict -> JSON
@jobs_bp.post('')
def create_job():
data = request.get_json(silent=True) or {}
if 'title' not in data:
return {'error': 'title is required'}, 400
new_id = max(JOBS) + 1
JOBS[new_id] = {'id': new_id, **data}
return JOBS[new_id], 201
def create_app(testing=False): # application factory
app = Flask(__name__)
app.config['TESTING'] = testing
app.register_blueprint(jobs_bp)
@app.errorhandler(404)
def not_found(_):
return jsonify(error='not found'), 404
return app
client = create_app(testing=True).test_client()
print(client.get('/jobs/1').get_json())
print(client.get('/jobs/9').status_code, client.get('/jobs/9').get_json())
print(client.post('/jobs', json={'title': 'Data Engineer'}).get_json())
print(client.post('/jobs', json={}).status_code)
Key Points
- Routes by decorator; return str, dict (JSON) or (body, status)
- request and g are request-scoped proxies; only valid inside a request
- Blueprints split features; create_app() factory makes testing easy
- abort() and errorhandler for HTTP errors; gunicorn in production, never flask run
Q85Flatten a nested list of arbitrary depth, recursively and iteratively.
IntermediateCoding
Answer
The recursive version is the natural one: walk the list, and for each element either yield it or recurse into it if it is itself a list. Writing it as a generator keeps memory flat and lets the caller decide whether to materialise. Check for list (or more generally collections.abc.Iterable while excluding str and bytes, because strings are iterable and would recurse forever character by character).
The iterative version uses an explicit stack to avoid Python's recursion limit on very deep nesting: push the elements in reverse so they pop in order, and whenever you pop a list, push its items. Both are O(n) in the total number of leaves.
Mention that for a one-level flatten you would use itertools.chain.from_iterable or a nested comprehension, and that in NumPy the same idea is ndarray.ravel() or flatten().
from collections.abc import Iterable
from itertools import chain
def flatten(items): # recursive generator
for x in items:
if isinstance(x, Iterable) and not isinstance(x, (str, bytes)):
yield from flatten(x)
else:
yield x
def flatten_iter(items): # explicit stack, no recursion limit
stack, out = list(reversed(items)), []
while stack:
x = stack.pop()
if isinstance(x, list):
stack.extend(reversed(x))
else:
out.append(x)
return out
nested = [1, [2, [3, [4, 'ab']]], (5, 6), []]
print(list(flatten(nested))) # [1, 2, 3, 4, 'ab', 5, 6]
print(flatten_iter([1, [2, [3, [4]]], [5]])) # [1, 2, 3, 4, 5]
one_level = [[1, 2], [3], [4, 5]]
print(list(chain.from_iterable(one_level)))
print([x for row in one_level for x in row])
deep = [1]
for _ in range(5000):
deep = [deep]
print(flatten_iter(deep)) # works; the recursive one would hit the limit
Key Points
- Recursive generator with yield from; exclude str and bytes from recursion
- Iterative stack version avoids RecursionError on deep nesting
- One level: chain.from_iterable or a double comprehension
Q86Implement binary search, and explain when to use the bisect module instead.
IntermediateCoding
Answer
Binary search finds a value in a sorted sequence in O(log n) by halving the range each step. Keep lo and hi as inclusive bounds (or hi exclusive, but pick one and be consistent), compute mid = (lo + hi) // 2 (Python ints do not overflow, so the classic overflow trick is not needed), and move lo or hi past mid. The common off-by-one bugs are using mid instead of mid + 1 or mid - 1, and the loop condition lo < hi versus lo <= hi; say which convention you use and why it terminates.
In real code, use the bisect module: bisect_left returns the insertion point for a value keeping the list sorted (the first index with an element >= value), bisect_right the index after any equal elements, and insort inserts while keeping order. bisect_left is also how you find the first occurrence in a list with duplicates, or the count of elements less than x.
State the precondition clearly: the input must be sorted, and sorting first costs O(n log n), so binary search only pays off when you search many times or the data is already sorted.
import bisect
def binary_search(xs, target):
lo, hi = 0, len(xs) - 1 # inclusive bounds
while lo <= hi:
mid = (lo + hi) // 2
if xs[mid] == target:
return mid
if xs[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
xs = [2, 3, 5, 5, 5, 8, 13]
print(binary_search(xs, 8), binary_search(xs, 4)) # 5 -1
print(bisect.bisect_left(xs, 5), bisect.bisect_right(xs, 5)) # 2 5
print('count of 5s:', bisect.bisect_right(xs, 5) - bisect.bisect_left(xs, 5))
print('elements < 6:', bisect.bisect_left(xs, 6))
bisect.insort(xs, 7)
print(xs) # [2, 3, 5, 5, 5, 7, 8, 13]
# Grade lookup with bisect: a classic 'use bisect for breakpoints' pattern
def grade(score, breakpoints=(50, 60, 70, 80), grades='FDCBA'):
return grades[bisect.bisect(breakpoints, score)]
print([grade(s) for s in (45, 50, 79, 95)]) # ['F', 'D', 'B', 'A'] (79 -> bisect gives 3)
Key Points
- Halve the range each step; O(log n); input must be sorted
- Pick inclusive or exclusive bounds and keep the loop condition consistent
- bisect_left/bisect_right give insertion points and handle duplicates
- insort keeps a list sorted; bisect also solves breakpoint lookups
Q87Check whether brackets in an expression are balanced using a stack.
IntermediateCoding
Answer
Balanced brackets is the standard stack question. Scan the string once: push every opening bracket; for a closing bracket, the stack must be non-empty and its top must be the matching opener, otherwise the string is unbalanced. At the end the stack must be empty. A dict mapping closers to openers keeps the code short, and a list with append and pop is the stack. O(n) time, O(n) space in the worst case.
Edge cases to say aloud: empty string is balanced, a closing bracket with an empty stack fails immediately, and non-bracket characters are ignored. If the interviewer wants the index of the first error, store (char, index) pairs on the stack.
The follow-ups are the other classic stack problems: evaluate a postfix expression, implement a min-stack with O(1) minimum, and the 'next greater element' monotonic stack. Mention collections.deque if they ask for a queue; list.pop(0) is O(n).
def is_balanced(s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch in '([{':
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack
for expr in ['{[()]}', '([)]', '((', '', 'a[b]{c}(d)', ')(']:
print(repr(expr), is_balanced(expr))
# True False False True True False
class MinStack: # common follow-up: O(1) min
def __init__(self):
self.items, self.mins = [], []
def push(self, x):
self.items.append(x)
self.mins.append(min(x, self.mins[-1]) if self.mins else x)
def pop(self):
self.mins.pop()
return self.items.pop()
def min(self):
return self.mins[-1]
ms = MinStack()
for v in (5, 2, 7, 1):
ms.push(v)
print(ms.min(), ms.pop(), ms.min()) # 1 1 2
Key Points
- Push openers; on a closer, pop and compare; stack must end empty
- O(n) time; list append/pop is the stack
- Follow-ups: min-stack, postfix evaluation, next greater element
Q88Maximum subarray sum (Kadane's algorithm) and merging overlapping intervals.
IntermediateCoding
Answer
Kadane's algorithm finds the contiguous subarray with the largest sum in O(n). Keep a running sum current; at each element decide whether to extend the previous subarray or start fresh at this element (current = max(x, current + x)), and track the best seen. Initialise both with the first element so an all-negative array returns its largest element rather than 0, which is the usual bug. If the interviewer wants the subarray itself, record the start index when you restart and the end index when best improves.
Merge intervals: sort by start, then walk the list keeping a merged output; if the current interval starts at or before the end of the last merged one, extend that end with max, otherwise append. Sorting dominates at O(n log n). Clarify whether touching intervals ([1, 3] and [3, 5]) should merge, and avoid mutating the caller's input by building new lists.
Both appear in HackerRank-style tests because they test the 'decide at each step' pattern without heavy data structures.
def max_subarray(nums):
best = current = nums[0]
start = best_start = best_end = 0
for i in range(1, len(nums)):
if current + nums[i] < nums[i]: # start fresh
current, start = nums[i], i
else:
current += nums[i]
if current > best:
best, best_start, best_end = current, start, i
return best, nums[best_start:best_end + 1]
print(max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # (6, [4, -1, 2, 1])
print(max_subarray([-3, -1, -2])) # (-1, [-1])
def merge_intervals(intervals):
merged = []
for start, end in sorted(intervals):
if merged and start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
print(merge_intervals([[1, 3], [8, 10], [2, 6], [15, 18], [17, 20]]))
# [[1, 6], [8, 10], [15, 20]]
print(merge_intervals([[1, 3], [3, 5]])) # [[1, 5]] touching intervals merge
Key Points
- Kadane: current = max(x, current + x); initialise with the first element
- Track indices if the subarray itself is wanted
- Intervals: sort by start, extend or append; O(n log n)
- Clarify touching intervals and do not mutate the input
Q89Implement an LRU cache with O(1) get and put.
IntermediateCoding
Answer
An LRU (least recently used) cache evicts the entry that was accessed longest ago when it is full. The O(1) design is a hash map for lookup plus a doubly linked list for recency order: get moves the node to the front, put inserts at the front and evicts from the back. In Python the short interview answer is collections.OrderedDict, which is exactly that structure: move_to_end(key) on get and on update, and popitem(last=False) to evict the oldest.
Say the invariants: capacity is fixed, every get counts as a use, and an update of an existing key also refreshes recency. If the interviewer asks for the hand-rolled version, sketch the node class with prev and next pointers and sentinel head and tail nodes to avoid None checks.
Then connect it to production: functools.lru_cache gives you this for pure functions, and the pitfalls are unbounded maxsize=None on a server, caching methods (the cache holds self and leaks instances), and mutable arguments that are unhashable.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # mark as recently used
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.capacity:
self.data.popitem(last=False) # evict least recently used
cache = LRUCache(2)
cache.put(1, 'a'); cache.put(2, 'b')
print(cache.get(1)) # 'a' (1 is now most recent)
cache.put(3, 'c') # evicts 2
print(cache.get(2)) # -1
print(list(cache.data)) # [1, 3]
from functools import lru_cache
@lru_cache(maxsize=128)
def slow_square(n):
return n * n
slow_square(4); slow_square(4)
print(slow_square.cache_info()) # hits=1 misses=1
Key Points
- Hash map + doubly linked list gives O(1) get and put
- OrderedDict: move_to_end on use, popitem(last=False) to evict
- Updates refresh recency too; capacity is enforced on put
- functools.lru_cache is the library version; bound maxsize on servers
Q90Implement merge sort and quicksort in Python, and explain why you still call sorted() in real code.
IntermediateCoding
Answer
Sorting algorithm questions test whether you understand divide and conquer and complexity, not whether you would ship your own sort. Merge sort splits the list in half, sorts each half recursively, and merges the two sorted halves in linear time: O(n log n) in every case, stable, but O(n) extra memory. Quicksort picks a pivot, partitions elements into smaller and larger, and recurses: O(n log n) on average with better constants and in-place partitioning, but O(n squared) in the worst case (sorted input with a bad pivot), which randomised pivots make vanishingly unlikely.
Python's built-in sorted() and list.sort() use Timsort (a hybrid of merge sort and insertion sort that exploits existing runs), are stable, written in C, and faster than anything you will write in Python by a large factor. The interview answer is: implement the algorithm they ask for, state its complexity and stability, and then say that in production you use sorted() with a key function, and heapq or bisect when you only need the top k or a sorted insertion.
Be ready for the follow-ups: why is merge sort preferred for linked lists and external sorting (sequential access, no random indexing), why stability matters (sorting by salary then by city keeps the salary order inside each city), and the bubble, selection and insertion sorts for completeness (all O(n squared); insertion sort is the one that is fast on nearly sorted data).
import random
def merge_sort(xs):
if len(xs) <= 1:
return list(xs)
mid = len(xs) // 2
left, right = merge_sort(xs[:mid]), merge_sort(xs[mid:])
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= keeps it stable
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
out.extend(left[i:]); out.extend(right[j:])
return out
def quicksort(xs):
if len(xs) <= 1:
return list(xs)
pivot = random.choice(xs) # randomised pivot avoids the sorted-input worst case
less = [x for x in xs if x < pivot]
equal = [x for x in xs if x == pivot]
more = [x for x in xs if x > pivot]
return quicksort(less) + equal + quicksort(more)
def insertion_sort(xs): # fast on nearly sorted data
xs = list(xs)
for i in range(1, len(xs)):
cur, j = xs[i], i - 1
while j >= 0 and xs[j] > cur:
xs[j + 1] = xs[j]; j -= 1
xs[j + 1] = cur
return xs
data = [38, 27, 43, 3, 9, 82, 10]
print(merge_sort(data), quicksort(data), insertion_sort(data))
print(merge_sort(data) == sorted(data) == quicksort(data))
people = [('Asha', 'Pune', 9), ('Bilal', 'Noida', 7), ('Chen', 'Pune', 12)]
print(sorted(people, key=lambda p: (p[1], -p[2]))) # city asc, salary desc
Key Points
- Merge sort: O(n log n) always, stable, O(n) extra memory
- Quicksort: O(n log n) average, O(n squared) worst case, randomise the pivot
- sorted()/list.sort() use Timsort in C: stable and far faster; use key= for multi-field sorts
- Insertion sort is the O(n squared) sort worth knowing: fast on nearly sorted input
Q91Implement BFS and DFS on a graph in Python, find the shortest path in an unweighted graph, and detect a cycle.
IntermediateCoding
Answer
Represent a graph as a dict of adjacency lists: node to list of neighbours. Breadth-first search uses a queue (collections.deque, never list.pop(0)) and a visited set; it visits nodes in order of distance from the start, which is why it gives the shortest path in an unweighted graph when you record each node's parent and walk back from the target. Depth-first search goes as deep as possible first, using recursion or an explicit stack; it is the tool for connectivity, topological ordering and cycle detection.
Both are O(V + E) time and O(V) space. The bugs interviewers watch for: marking a node visited when it is dequeued instead of when it is enqueued (duplicates in the queue), forgetting that recursion depth is limited to about 1000 frames (use the iterative version for large graphs), and using a list for the queue.
Cycle detection differs by graph type. In a directed graph, DFS with three colours (white unvisited, grey on the current path, black done) finds a cycle when it meets a grey node. In an undirected graph, a visited neighbour that is not the parent means a cycle, or you use union-find. Mention that Dijkstra replaces BFS when edges have weights, with heapq as the priority queue.
from collections import deque
graph = {
'A': ['B', 'C'], 'B': ['D'], 'C': ['D', 'E'],
'D': ['F'], 'E': ['F'], 'F': [],
}
def bfs_shortest_path(g, start, goal):
parent = {start: None}
queue = deque([start])
while queue:
node = queue.popleft()
if node == goal:
path = []
while node is not None:
path.append(node); node = parent[node]
return path[::-1]
for nxt in g[node]:
if nxt not in parent: # mark when enqueued
parent[nxt] = node
queue.append(nxt)
return None
def dfs_iterative(g, start):
seen, order, stack = set(), [], [start]
while stack:
node = stack.pop()
if node in seen:
continue
seen.add(node); order.append(node)
stack.extend(reversed(g[node])) # keep left-to-right order
return order
def has_cycle_directed(g):
WHITE, GREY, BLACK = 0, 1, 2
colour = {n: WHITE for n in g}
def visit(n):
colour[n] = GREY
for nxt in g[n]:
if colour[nxt] == GREY or (colour[nxt] == WHITE and visit(nxt)):
return True
colour[n] = BLACK
return False
return any(colour[n] == WHITE and visit(n) for n in g)
print(bfs_shortest_path(graph, 'A', 'F')) # ['A', 'B', 'D', 'F']
print(dfs_iterative(graph, 'A')) # ['A', 'B', 'D', 'F', 'C', 'E']
print(has_cycle_directed(graph)) # False
graph['F'] = ['A']
print(has_cycle_directed(graph)) # True
Key Points
- Adjacency dict; BFS with deque and a visited set gives shortest paths in unweighted graphs
- DFS recursive or with a stack; recursion limit means iterative for big graphs
- Mark visited when enqueued, not when dequeued
- Directed cycle: three-colour DFS; undirected: visited non-parent neighbour or union-find
Q92Use heapq for top-k and k-th largest problems, and merge k sorted lists.
IntermediateCoding
Answer
A heap is a tree-shaped array where the smallest element is always at index 0; heapq gives you heappush and heappop in O(log n) and heapify in O(n). Top-k problems are heap problems: to find the k largest of n items, keep a min-heap of size k, push each item and pop the smallest when the heap exceeds k; the heap then holds the k largest in O(n log k), better than sorting everything (O(n log n)) when k is small. heapq.nlargest and nsmallest wrap this and accept a key function.
k-th largest is the same heap of size k, answer at heap[0]. Python's heap is a min-heap only, so for max-heap behaviour push negated values, or tuples of (-priority, item). Tuples also give you a priority queue, with a tie-breaker counter so that unorderable payloads never get compared.
Merging k sorted lists is heapq.merge in the standard library, or by hand: push the first element of each list with its list index, pop the smallest, and push the next element from that same list; O(N log k) for N total elements. Mention collections.Counter.most_common, which uses heapq.nlargest internally, and that the heap invariant breaks if you mutate elements in place after pushing them.
import heapq
scores = [72, 95, 88, 61, 99, 84, 90]
def top_k(items, k):
heap = []
for x in items:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap) # drop the smallest
return sorted(heap, reverse=True)
print(top_k(scores, 3), heapq.nlargest(3, scores)) # [99, 95, 90] [99, 95, 90]
print(heapq.nsmallest(2, scores)) # [61, 72]
def kth_largest(items, k):
heap = items[:k]
heapq.heapify(heap)
for x in items[k:]:
if x > heap[0]:
heapq.heapreplace(heap, x)
return heap[0]
print(kth_largest(scores, 2)) # 95
# Max-heap by negation, priority queue with a tie-breaker
pq, counter = [], 0
for priority, task in [(2, 'email'), (1, 'deploy'), (2, 'lint')]:
heapq.heappush(pq, (-priority, counter, task)); counter += 1
print([heapq.heappop(pq)[2] for _ in range(len(pq))]) # ['email', 'lint', 'deploy']
def merge_k(lists):
heap = [(lst[0], i, 0) for i, lst in enumerate(lists) if lst]
heapq.heapify(heap)
out = []
while heap:
val, i, j = heapq.heappop(heap)
out.append(val)
if j + 1 < len(lists[i]):
heapq.heappush(heap, (lists[i][j + 1], i, j + 1))
return out
print(merge_k([[1, 4, 9], [2, 3], [5, 6, 7]])) # [1, 2, 3, 4, 5, 6, 7, 9]
print(list(heapq.merge([1, 4, 9], [2, 3], [5, 6, 7])))
Key Points
- Min-heap of size k gives top-k in O(n log k); heapq.nlargest/nsmallest take a key
- k-th largest is heap[0] of that size-k heap; heapreplace avoids two operations
- Max-heap by negating; priority queue with (priority, counter, item) tuples
- Merge k sorted lists with heapq.merge or a heap of (value, list index, position)
Q93How do you automate a browser test with Selenium and pytest in Python?
IntermediateAutomation
Answer
Python automation interview questions for QA automation and SDET roles expect you to drive a browser with Selenium from Python and organise the tests with pytest. The core API: start a WebDriver (Chrome or Firefox, usually headless in CI), navigate with driver.get, locate elements with By.ID, By.CSS_SELECTOR or By.XPATH, interact with send_keys and click, and read text or attributes for assertions. Selenium 4 manages browser drivers automatically through Selenium Manager, so the old 'download chromedriver' step is gone.
The part that separates candidates is waiting. Never use time.sleep; use WebDriverWait(driver, 10).until(EC.visibility_of_element_located(...)), and explain implicit versus explicit waits (explicit is per-condition and preferred). Use the Page Object Model so selectors live in one class per page and tests read like user steps.
With pytest, a conftest.py fixture creates and quits the driver per test or per session, @pytest.mark.parametrize runs the same test for several inputs, and markers separate smoke from regression suites. Be ready to compare Selenium with Playwright (auto-waiting, faster, better tracing) since many teams are migrating.
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
@pytest.fixture
def driver():
opts = Options()
opts.add_argument('--headless=new')
d = webdriver.Chrome(options=opts) # Selenium Manager fetches the driver
yield d
d.quit()
# pages/login_page.py (Page Object)
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
EMAIL = (By.ID, 'email')
SUBMIT = (By.CSS_SELECTOR, 'button[type=submit]')
ERROR = (By.CSS_SELECTOR, '.error')
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def open(self, url):
self.driver.get(url)
return self
def login(self, email):
self.wait.until(EC.visibility_of_element_located(self.EMAIL)).send_keys(email)
self.driver.find_element(*self.SUBMIT).click()
return self
def error_text(self):
return self.wait.until(EC.visibility_of_element_located(self.ERROR)).text
# tests/test_login.py
@pytest.mark.parametrize('email', ['', 'not-an-email'])
def test_invalid_email_shows_error(driver, email):
page = LoginPage(driver).open('https://staging.example.test/login').login(email)
assert 'valid email' in page.error_text().lower()
Key Points
- WebDriver, By locators, send_keys/click, assertions on text; headless in CI
- Explicit waits with WebDriverWait and expected_conditions; never time.sleep
- Page Object Model keeps selectors out of tests
- pytest fixtures for driver lifecycle, parametrize for data-driven tests
Q94How do you write a reliable automation script in Python: calling REST APIs with requests, retries and timeouts, and running shell commands with subprocess?
IntermediateAutomation
Answer
Scripting rounds (DevOps, SDET, data operations) test whether your automation survives the real world. For HTTP, use requests with an explicit timeout on every call (the default is to wait forever), a Session for connection reuse and shared headers, raise_for_status() to turn 4xx and 5xx into exceptions, and a retry policy with exponential backoff for 429 and 5xx responses, either via urllib3's Retry mounted on an HTTPAdapter or a small loop. Never retry non-idempotent POSTs blindly.
For shell commands, use subprocess.run with a list of arguments (not shell=True with string interpolation, which is a command-injection risk), capture_output=True, text=True, check=True so failures raise, and a timeout. Read secrets from environment variables or a secrets manager, never from the script, and parse arguments with argparse so the script is usable from cron and CI.
Add logging instead of print, exit with a non-zero code on failure so the scheduler notices, and make the script idempotent so a re-run after a crash does not duplicate work. Those four habits are what a senior reviewer looks for in the first thirty seconds.
import argparse, logging, os, subprocess, sys
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger(__name__)
def make_session():
retry = Retry(total=3, backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({'GET', 'HEAD'})) # idempotent only
s = requests.Session()
s.mount('https://', HTTPAdapter(max_retries=retry))
s.headers['Authorization'] = f"Bearer {os.environ.get('API_TOKEN', '')}"
return s
def fetch_jobs(session, base_url):
resp = session.get(f'{base_url}/jobs', params={'skill': 'python'}, timeout=(3, 10))
resp.raise_for_status()
return resp.json()
def disk_usage(path):
result = subprocess.run(['du', '-sh', path], capture_output=True, text=True,
check=True, timeout=30)
return result.stdout.split()[0]
def main(argv=None):
parser = argparse.ArgumentParser(description='Sync jobs and report disk usage')
parser.add_argument('--base-url', default='https://api.example.test')
parser.add_argument('--path', default='.')
args = parser.parse_args(argv)
log.info('disk usage of %s: %s', args.path, disk_usage(args.path))
try:
jobs = fetch_jobs(make_session(), args.base_url)
log.info('fetched %d jobs', len(jobs))
except requests.RequestException as e:
log.error('API call failed: %s', e)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
Key Points
- requests: timeout on every call, Session, raise_for_status, Retry with backoff for 429/5xx
- subprocess.run with an argument list, check=True, capture_output, timeout; avoid shell=True
- argparse, logging, env vars for secrets, non-zero exit codes
- Make scripts idempotent so re-runs are safe
Q95What other Python automation interview questions come up: files and folders, Excel and CSV, scheduling, web scraping, and Selenium gotchas?
IntermediateAutomation
Answer
Beyond browser tests, automation interviews check whether you can glue systems together safely. Files and folders: pathlib for paths, shutil for copy, move and archive, glob or Path.rglob for matching, os.scandir for fast directory listing, and tempfile for safe scratch space; always write to a temporary file and rename, so a crash never leaves a half-written report. CSV and Excel: the csv module with DictReader and newline='' for plain files, openpyxl for .xlsx (cells, formulas, formatting), and pandas when you need joins, pivots or cleaning; xlrd no longer reads .xlsx.
Scheduling: cron (or Windows Task Scheduler) for anything simple, APScheduler or Celery beat inside an application, and the rule that scheduled scripts must be idempotent, log to a file, and exit non-zero on failure. Web scraping: requests plus BeautifulSoup for static pages, Playwright or Selenium when content is rendered by JavaScript, respect robots.txt and rate limits, set a User-Agent, retry with backoff, and prefer an official API when one exists.
Selenium gotchas interviewers love: switch_to.frame for iframes, switch_to.alert for JavaScript alerts, switch_to.window with window_handles for new tabs, StaleElementReferenceException (re-find the element after the DOM changes), save_screenshot on failure, and headless mode plus a fixed window size in CI. Each is a one-line answer plus a sentence on why it happens.
import csv, shutil, tempfile
from pathlib import Path
base = Path(tempfile.mkdtemp()) # safe scratch directory
src = base / 'leads.csv'
src.write_text('name,city,score\nAsha,Pune,82\nBilal,Noida,67\nChen,Pune,91\n', encoding='utf-8')
# 1. Read with DictReader, filter, write atomically (temp file then rename)
with src.open(newline='', encoding='utf-8') as f:
rows = [r for r in csv.DictReader(f) if int(r['score']) >= 80]
tmp = base / 'shortlist.csv.tmp'
with tmp.open('w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['name', 'city', 'score'])
writer.writeheader()
writer.writerows(rows)
tmp.replace(base / 'shortlist.csv') # atomic on the same filesystem
print((base / 'shortlist.csv').read_text(encoding='utf-8'))
# 2. Organise files into folders by extension
for name in ('a.pdf', 'b.docx', 'c.pdf'):
(base / name).write_bytes(b'x')
for p in base.iterdir():
if p.is_file() and p.suffix in ('.pdf', '.docx'):
dest = base / p.suffix.lstrip('.')
dest.mkdir(exist_ok=True)
shutil.move(str(p), dest / p.name)
print(sorted(str(p.relative_to(base)) for p in base.rglob('*') if p.is_file()))
# 3. Excel with openpyxl (install: pip install openpyxl)
# from openpyxl import Workbook
# wb = Workbook(); ws = wb.active
# ws.append(['name', 'score']); ws.append(['Asha', 82])
# ws['C2'] = '=B2*1.1' # formulas are strings
# wb.save(base / 'report.xlsx')
# 4. Selenium gotchas (sketch)
# driver.switch_to.frame('checkout') # iframe
# driver.switch_to.alert.accept() # JS alert
# driver.switch_to.window(driver.window_handles[-1]) # new tab
# driver.save_screenshot('failure.png') # on assertion failure
shutil.rmtree(base)
Key Points
- pathlib, shutil, glob, tempfile; write to a temp file and rename for atomic output
- csv.DictReader/DictWriter with newline=''; openpyxl for .xlsx; pandas for joins and pivots
- cron or APScheduler; scheduled scripts must be idempotent and exit non-zero on failure
- Scraping: requests + BeautifulSoup, Playwright/Selenium for JS pages, respect robots.txt and rate limits
- Selenium: frames, alerts, window handles, StaleElementReferenceException, screenshots
Q96Explain CPython's memory management: reference counting, the cyclic GC, weakref, and immortal objects.
AdvancedMemory
Answer
CPython's primary mechanism is reference counting: every object carries a count of references, incremented and decremented as names bind and unbind; when it hits zero the object is freed IMMEDIATELY and deterministically, which is why CPython's memory behavior is more predictable than a JVM's and why file handles historically closed promptly when the last reference died (still: use `with`, since PyPy and exceptions break that assumption). sys.getrefcount(obj) shows the count (one higher, since the call itself holds a reference). Refcounting cannot free reference CYCLES, two objects pointing at each other never reach zero, so a second collector, the generational cyclic GC (gc module), periodically finds unreachable cycles among container objects. Three generations exist because most objects die young; survivors get promoted and scanned less often; thresholds via gc.get_threshold().
Objects with __del__ in cycles were historically problematic; since PEP 442 finalizers run safely, but relying on __del__ for critical cleanup remains wrong, use context managers. Practical consequences: gc.collect() forces a full collection (occasionally used after big batch jobs before forking); gc.freeze() moves current objects out of consideration, a real optimization for pre-fork servers like gunicorn because copy-on-write memory pages stay shared when the GC does not touch refcount/gc headers of frozen objects; some latency-sensitive systems tune or disable the cyclic GC (Instagram famously did) and rely on refcounting plus process recycling. weakref references an object WITHOUT raising its refcount: weakref.ref(obj) returns a callable yielding the object or None once dead; WeakValueDictionary and WeakKeyDictionary build caches and registries that do not keep entries alive, the standard fix for observer patterns and caches that would otherwise leak. PEP 683 (3.12) introduced immortal objects: True, False, None, small ints and interned strings get a special refcount that never changes, which keeps them safely shareable across threads and interpreters and preserves copy-on-write pages, groundwork that free-threading builds on. Memory also does not always return to the OS: CPython's pymalloc arenas and free lists retain capacity, so RSS staying high after a spike is normal, not necessarily a leak.
import gc, sys, weakref
class Node:
def __init__(self, name):
self.name = name
self.other = None
a, b = Node('a'), Node('b')
a.other, b.other = b, a # reference cycle
del a, b # refcounts never reach zero...
print(gc.collect() > 0) # ...cyclic GC reclaims them: True
class Session:
pass
s = Session()
alive = weakref.ref(s)
print(alive() is s) # True: no refcount held
del s
print(alive()) # None: object died despite our ref
cache = weakref.WeakValueDictionary()
obj = Session()
cache['sess-1'] = obj # entry vanishes when obj dies
del obj
print(dict(cache)) # {}
print(sys.getrefcount(None)) # huge/fixed: None is immortal (PEP 683)
Q97What does __slots__ actually change about instance layout, and when is the optimization worth it?
AdvancedMemory
Answer
By default every instance carries a __dict__, a real per-instance dictionary holding its attributes, plus a __weakref__ slot. That is flexible (attach anything at runtime) but costly: for a small object, the dict can dominate total footprint. Declaring __slots__ = ('x', 'y') at class level suppresses __dict__ creation and instead allocates fixed offsets in the instance for exactly those attributes, implemented via slot descriptors reading C-level struct offsets.
Effects: memory drops substantially for attribute-light objects (commonly 2-3x smaller; measure with sys.getsizeof plus the dict it no longer carries, or pympler.asizeof for deep size), attribute access gets a bit faster (offset load beats hashing), and assigning an undeclared attribute raises AttributeError: 'Point' object has no attribute 'z', which doubles as typo protection. When it is worth it: only at volume, millions of point/row/token/order objects in a data pipeline, graph, or in-memory cache; for a service instantiating hundreds of objects, it is noise, and dicts/tuples/NumPy arrays may be better vehicles at extreme scale anyway. The sharp edges are the real interview content.
Inheritance: every class in the chain must declare __slots__ (empty tuple if it adds nothing) or instances silently regain a __dict__ and the saving vanishes; a subclass of a slotted class that omits __slots__ does exactly that. Multiple inheritance from two classes with non-empty, differing slots fails with TypeError (conflicting layouts). Weak references need '__weakref__' listed explicitly once. cached_property and other __dict__-writing tools break (no __dict__ to write into), the property/descriptor route works fine.
Class-level defaults cannot share a name with a slot (the slot IS a class attribute, the descriptor). Pickling of slotted objects uses __getstate__/__setstate__ paths that mostly just work but bite with custom __reduce__. The ergonomic modern route: @dataclass(slots=True) (3.10+) generates the slots declaration from the fields, giving you the memory win without hand-maintaining the tuple, and attrs has @define(slots=True) as its default. Quote the measurement discipline: decide with tracemalloc before/after on a realistic load, not on faith.
import sys
from dataclasses import dataclass
class Loose:
def __init__(self, x, y):
self.x, self.y = x, y
class Packed:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x, self.y = x, y
l, p = Loose(1, 2), Packed(1, 2)
print(sys.getsizeof(l) + sys.getsizeof(l.__dict__)) # object + its dict
print(sys.getsizeof(p)) # notably smaller
p.x = 10
try:
p.z = 3
except AttributeError as e:
print(e) # 'Packed' object has no attribute 'z' and no __dict__ for setting new attributes
class Child(Packed): # forgot __slots__ = ()
pass
c = Child(1, 2)
c.anything = 'works' # __dict__ is BACK: saving silently lost
@dataclass(slots=True) # 3.10+: slots without hand-writing them
class Candidate:
id: int
score: float
Q98A Python service's memory climbs steadily in production until the pod is OOM-killed. How do you investigate and what are the usual causes?
AdvancedMemory
Answer
Triage first: is it a leak (monotonic climb across requests) or expected high-water usage (spikes on big payloads that never return to baseline because CPython's allocator retains arenas and free lists, RSS staying elevated after a burst is normal and not a leak). Plot worker RSS against request count; if restarts reset it and the slope is steady, hunt a leak. Instrumentation, cheapest first: enable gunicorn --max-requests as a tourniquet while you investigate; attach py-spy dump to confirm no thread is stuck accumulating; then tracemalloc in a canary worker, start it at boot (tracemalloc.start(25) for deep tracebacks), snapshot every N minutes, and compare_to(previous, 'traceback') to get the exact allocation stacks that grow, this usually names the culprit within an hour. objgraph.show_growth() complements it by object type, and gc.get_objects() sampling plus sys.getsizeof spot-checks large offenders.
The recurring culprits, in rough frequency order from real postmortems: unbounded caches, an @lru_cache(maxsize=None) or hand-rolled dict keyed by user input (URLs, query strings) that grows forever, and lru_cache on instance METHODS pinning every self; module-level or class-level mutable collections used as accumulators (a global list of 'recent errors' appended per request); event-listener/callback registries that register per request and never unregister (fix with weakref); large objects captured in closures held by long-lived tasks, or exception objects stored whole (a traceback keeps every frame's locals alive, so caching exceptions leaks entire request contexts, clear with exc.__traceback__ = None or store strings); session/connection objects created per request instead of pooled, each holding native buffers; pandas DataFrames sliced views keeping giant parents alive; and C-extension leaks invisible to tracemalloc (climbing RSS with flat Python heap points there; valgrind or jemalloc profiling territory). Async-specific: an ever-growing set of never-awaited tasks or unbounded asyncio.Queue depths, inspect len(asyncio.all_tasks()). Prevention posture to close on: bounded caches with TTLs (cachetools), queue maxsizes, memory-based worker recycling, RSS dashboards with slope alerts BEFORE the OOM killer arrives, and load tests that assert flat memory across sustained traffic in CI, so the leak is caught in review, not on-call.
# canary worker: allocation-growth tracker
import tracemalloc, time, logging
tracemalloc.start(25) # keep 25-frame tracebacks
baseline = tracemalloc.take_snapshot()
def report_growth():
snap = tracemalloc.take_snapshot()
for stat in snap.compare_to(baseline, 'traceback')[:5]:
logging.warning('LEAK? +%.1f KiB (%d blocks)\n%s',
stat.size_diff / 1024, stat.count_diff,
'\n'.join(stat.traceback.format()[-3:]))
# Typical culprit #1: unbounded cache keyed by user input
from functools import lru_cache
@lru_cache(maxsize=None) # grows per unique URL forever
def fetch_preview(url: str): ...
# Fix: bound + TTL
from cachetools import TTLCache
previews = TTLCache(maxsize=10_000, ttl=600)
# Typical culprit #2: stored exceptions pin whole frames
recent_errors = []
def record(exc: Exception):
recent_errors.append(f'{type(exc).__name__}: {exc}') # strings, not exc
Q99Free-threaded CPython (PEP 703) and subinterpreters (PEP 734): what changed mechanically, and when would you actually use them?
AdvancedGIL
Answer
Free-threaded CPython removes the GIL in a separate build (3.13 experimental as python3.13t; officially supported from 3.14 per PEP 779, still not the default binary). Mechanically, plain reference counting is replaced by biased reference counting: each object keeps a fast local count for its owning thread plus an atomic shared count for others, avoiding contended atomic operations on the common single-thread path; immortal objects (PEP 683) sidestep counting entirely for singletons; deferred reference counting cuts overhead for known long-lived references; container internals (list, dict) get per-object locking with lock-free fast reads; and pymalloc is replaced by the thread-safe mimalloc. The costs: single-threaded overhead in the several-percent range that has been shrinking release over release, and an ecosystem constraint, C extensions must be audited for thread-safety and rebuilt against the free-threaded ABI (wheels tagged cp313t/cp314t), declaring support via Py_mod_gil.
Python 3.15's stable ABI for free-threaded builds (PEP 803) removed the need to rebuild extensions for every minor release, and major scientific and infrastructure packages have been adding support, but 'is my dependency tree ready' remains the gating question in 2026, so the honest deployment posture is: measure your workload on the t build, and adopt where multi-core CPU parallelism in one process with shared memory genuinely beats a process pool, e.g. parallel feature engineering over a large shared DataFrame-like structure without pickling costs. Also note: dropping the GIL does not make YOUR code thread-safe; check-then-act races and non-atomic compound operations need locks exactly as before, and in fact latent races that the GIL's coarse scheduling masked can surface. Subinterpreters are the other axis: PEP 684 (3.12) gave each interpreter in a process its own GIL, and PEP 734 (3.14) exposed them in the stdlib via concurrent.interpreters, including an InterpreterPoolExecutor-style pattern.
Each interpreter has isolated modules and objects, sharing only explicitly (memoryview-able buffers, queues), giving process-like isolation with thread-like startup cost. Use them for plugin isolation or parallel work with modest communication; free-threading suits tight shared-state parallelism.
Key Points
- Biased refcounting + immortality + mimalloc + per-object locks replace the GIL
- Separate build (cp313t/cp314t ABI); C extensions must opt in via Py_mod_gil
- GIL removal does not remove YOUR races; locks still required
- PEP 684/734: per-interpreter GIL, stdlib concurrent.interpreters in 3.14
- Choose: free-threading for shared-state CPU parallelism, subinterpreters for isolation
Q100Why is `count += 1` not thread-safe, and how do Lock, RLock, and queue.Queue give you correct concurrent code?
AdvancedConcurrency
Answer
The GIL guarantees one thread executes bytecode at a time, but `count += 1` compiles to several instructions: load the value, add, store. A thread can be preempted between the load and the store, so two threads both load 100, both store 101, and one increment is lost; the GIL build since 3.10 rarely switches threads inside that statement, so a two-thread million-increment demo often prints exactly two million and lulls people into thinking it is safe; the free-threaded build shows the lost updates, and a check-then-act on a shared dict shows them on any build the moment there is a pause between the check and the write. The same lost-update shape hits dict check-then-set ('if key not in cache: cache[key] = compute()' can compute twice and interleave), list read-modify-write, and any compound invariant across multiple structures.
Individual dict/list operations (a single .append, a single d[k] = v) are atomic in CPython today, but relying on that is fragile, undocumented-contract territory, and under the free-threaded build the safe stance is unchanged: protect compound operations with synchronization, because the GIL never protected YOUR invariants, only the interpreter's. Tools: threading.Lock is the mutex; always acquire via `with lock:` so exceptions release it; keep critical sections tiny and never call unknown/user code while holding one. RLock is reentrant, the same thread may re-acquire, needed when a locked method calls another locked method of the same object; a plain Lock deadlocks there.
Deadlock across multiple locks comes from inconsistent ordering (thread 1 takes A then B, thread 2 takes B then A); the discipline is a global lock ordering or a single coarser lock. threading.Event signals one-shot conditions (shutdown flags checked by worker loops); Condition supports wait/notify; Semaphore bounds concurrency (max N simultaneous downloads); Barrier synchronizes phases. queue.Queue is the highest-leverage tool: a thread-safe FIFO with blocking put/get, maxsize backpressure, and task_done/join accounting, which converts shared-state designs into producer-consumer pipelines where threads share NOTHING but the queue; most 'threading bug' interview scenarios dissolve into 'put it through a Queue'. For plain counters, mention that atomics do not exist in pure Python; use a lock, or itertools.count for id generation, or push aggregation into one consumer thread. Debugging: races are heisenbugs, so reason from invariants, and use sys.setswitchinterval only to REPRODUCE, never to fix; the faulthandler module and py-spy dump find the deadlocked stacks.
import threading, time, queue
# 1. The race the GIL does NOT prevent: check-then-act on shared state
cache = {}
computed = []
def get_or_compute(key):
if key not in cache: # check
time.sleep(0.001) # any pause: I/O, a slow call
cache[key] = key * 2 # act: every thread that passed the check writes
computed.append(1)
return cache[key]
ts = [threading.Thread(target=get_or_compute, args=('k',)) for _ in range(5)]
[t.start() for t in ts]; [t.join() for t in ts]
print('computed', len(computed), 'times') # 5: duplicate work, lost updates
lock = threading.Lock()
cache.clear(); computed.clear()
def get_or_compute_safe(key):
with lock: # check AND act under one lock
if key not in cache:
time.sleep(0.001)
cache[key] = key * 2
computed.append(1)
return cache[key]
ts = [threading.Thread(target=get_or_compute_safe, args=('k',)) for _ in range(5)]
[t.start() for t in ts]; [t.join() for t in ts]
print('computed', len(computed), 'times') # 1
# 2. count += 1 is load/add/store and can interleave the same way. On the GIL
# build since 3.10 the switch rarely lands inside the statement, so this
# often prints 2000000 and lulls people; the free-threaded build shows the
# loss. Lock it regardless: the GIL never protected YOUR invariants.
count = 0
def unsafe():
global count
for _ in range(1_000_000):
count += 1 # load/add/store: preemptible mid-way
def safe():
global count
for _ in range(1_000_000):
with lock:
count += 1
ts = [threading.Thread(target=safe) for _ in range(2)]
[t.start() for t in ts]; [t.join() for t in ts]
print(count) # 2000000, guaranteed
# 3. Producer-consumer: share the queue, share nothing else
jobs: queue.Queue[str | None] = queue.Queue(maxsize=100) # backpressure
def process(url):
print('fetched', url)
def worker():
while True:
url = jobs.get()
if url is None: # poison pill shutdown
jobs.task_done()
break
process(url)
jobs.task_done()
workers = [threading.Thread(target=worker) for _ in range(4)]
[w.start() for w in workers]
for u in ['https://example.test/a', 'https://example.test/b']:
jobs.put(u) # blocks when full: bounded memory
for _ in workers:
jobs.put(None)
jobs.join() # wait until every task_done
[w.join() for w in workers]
Q101Advanced asyncio: how do cancellation, shielding, graceful shutdown, and task-leak prevention actually work?
AdvancedAsync
Answer
Cancellation is cooperative and flows through await points: task.cancel() arranges for CancelledError to be raised inside the task at its next suspension. Correct handling: let it propagate. Code may catch CancelledError to run cleanup but must re-raise; swallowing it makes tasks uncancellable, which breaks timeouts (asyncio.timeout and wait_for are implemented VIA cancellation) and hangs shutdown, one of the most common asyncio bugs in review.
Note CancelledError inherits from BaseException (since 3.8) precisely so bare `except Exception` does not eat it, an intentional design detail worth citing. Cleanup that itself must await during cancellation should be wrapped carefully; a finally block runs, but another cancellation can arrive inside it, and 3.11+ provides task.uncancel() plus asyncio.shield(coro) for the narrow cases where an operation must complete once started, e.g. shielding a payment-commit call so an impatient HTTP client disconnecting does not abort the money movement mid-flight; shield the smallest possible region, because shielded work outliving its parent is itself a leak vector. Graceful shutdown is choreography: catch SIGTERM via loop.add_signal_handler, stop accepting new work, cancel outstanding tasks (or let TaskGroup scoping do it), then await them with a bounded timeout so cleanup cannot hang the pod past Kubernetes' grace period, and finally close resources (asyncio.run does loop shutdown_asyncgens/close for you; servers built on uvicorn hook this into lifespan shutdown).
Task leaks: asyncio.create_task returns a Task the loop holds only WEAKLY, so a fire-and-forget task with no strong reference can be garbage-collected mid-execution and its exception silently lost; keep handles in a set with done-callback discard, or better, use TaskGroups so structure owns lifetimes; a global exception handler via loop.set_exception_handler catches the 'Task exception was never retrieved' class of silent failures and should page you. Complete the answer with debugging tools: asyncio.all_tasks() to enumerate stragglers at shutdown, debug mode's slow-callback logging for loop stalls, and contextvars for request-scoped state that survives await boundaries where thread-locals fail.
import asyncio, signal
running_tasks: set[asyncio.Task] = set()
def spawn(coro): # leak-proof fire-and-forget
t = asyncio.create_task(coro)
running_tasks.add(t)
t.add_done_callback(running_tasks.discard)
return t
async def commit_payment(order_id):
try:
# must-complete section: client disconnect cannot abort it
await asyncio.shield(psp_commit(order_id))
except asyncio.CancelledError:
await audit_log('cancelled AFTER commit protected', order_id)
raise # ALWAYS re-raise
async def main():
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
server = await start_server()
await stop.wait() # run until signal
server.close() # 1. stop intake
for t in list(running_tasks): # 2. cancel in-flight
t.cancel()
# 3. bounded drain: never hang past k8s grace period
if running_tasks: # asyncio.wait() rejects an empty set
await asyncio.wait(running_tasks, timeout=25)
asyncio.run(main())
Q102How does the descriptor protocol work, and how does it implement property, methods, and slots under the hood?
AdvancedInternals
Answer
A descriptor is any class-level attribute whose type defines __get__, __set__, or __delete__. Attribute access obj.x is not a plain dict lookup; type(obj).__getattribute__ orchestrates: it looks up 'x' on the type (walking the MRO), and if that class attribute is a DATA descriptor (defines __set__ or __delete__), its __get__ wins over the instance __dict__; otherwise the instance __dict__ wins; otherwise a NON-data descriptor (only __get__) is invoked; otherwise the plain class attribute returns; else __getattr__ is the final fallback. That precedence table explains half of Python's object model. property is a data descriptor, which is why assigning obj.attr routes to the setter instead of just shadowing in the instance dict.
Plain functions are non-data descriptors: function.__get__(obj, cls) returns a bound method with the instance pre-bound, which is all 'methods' are; classmethod and staticmethod are wrappers whose __get__ returns differently-bound callables. __slots__ works by creating slot descriptors that read/write fixed offsets in the instance layout instead of a __dict__. cached_property exploits the precedence rule deliberately: it is a NON-data descriptor, so after its first __get__ writes the value into the instance __dict__, subsequent lookups hit the dict first and never invoke the descriptor again, caching by architecture rather than by checking. Writing your own: implement __set_name__(self, owner, name) (3.6+) to learn the attribute name at class-creation time, then store per-instance data in the instance's __dict__ under a private key. Real-world descriptors are the machinery of Django ORM fields (Model.field is a descriptor mediating DB access), SQLAlchemy instrumented attributes, and validated-field libraries. The interview-winning demo is a reusable validation descriptor: one Positive() class shared by many attributes across many classes, something property cannot express without repetition, which is precisely the gap descriptors fill.
class Positive:
def __set_name__(self, owner, name):
self.private = '_' + name # learns its own name
def __get__(self, obj, objtype=None):
if obj is None:
return self # class access returns descriptor
return getattr(obj, self.private)
def __set__(self, obj, value): # data descriptor: beats __dict__
if value <= 0:
raise ValueError(f'{self.private[1:]} must be positive, got {value}')
setattr(obj, self.private, value)
class Offer:
ctc_lpa = Positive() # reusable across fields/classes
notice_days = Positive()
def __init__(self, ctc_lpa, notice_days):
self.ctc_lpa = ctc_lpa # routed through __set__
self.notice_days = notice_days
o = Offer(24, 30)
# o.ctc_lpa = -5 -> ValueError
# Methods are just descriptors:
bound = Offer.__init__.__get__(o, Offer) # manual bound method
print(type(bound).__name__) # method
Q103What are metaclasses, how does __init_subclass__ compare, and when is each genuinely justified?
AdvancedInternals
Answer
Classes are objects; their type is a metaclass, and the default metaclass is type itself. `class Foo(Base):` executes the body in a namespace dict, then calls type(name, bases, namespace), so type('Foo', (Base,), {'x': 1}) creates the identical class dynamically. A custom metaclass subclasses type and overrides __new__ or __init__ to intercept CLASS creation: rewriting the namespace, injecting or validating attributes, registering the class, or implementing class-level operators via methods on the metaclass (this is how enums make iteration over the class possible; note that `list[int]` needs no metaclass, it uses __class_getitem__ from PEP 560). The famous real users: Django's ModelBase turns class-level Field attributes into DB columns plus a Meta-driven table registration; ABCMeta enforces abstractmethod at instantiation; Enum, Protocol, and SQLAlchemy's declarative base are all metaclass-driven.
The modern honest answer, though, is that most historical metaclass jobs now have lighter tools: __init_subclass__(cls, **kwargs) (3.6+) is a hook on the PARENT class called whenever it is subclassed, perfect for plugin registries and subclass validation with zero metaclass machinery and no metaclass-conflict risk; __set_name__ covers attribute-name-aware fields; and class decorators cover post-creation rewriting (dataclass is a decorator, not a metaclass, precisely to compose better). Metaclass conflicts are the practical hazard: a class's metaclass must be a (non-strict) subclass of every base's metaclass, so mixing ABCMeta with another metaclass forces you to write a combined metaclass, a real integration papercut with libraries like Protocol. Interview framing that lands: 'I reach for __init_subclass__ or a decorator first; a metaclass is justified only when I must change what class creation MEANS for a whole hierarchy, an ORM/DSL situation.' Follow-ups to be ready for: __prepare__ can return an ordered/validating namespace mapping; and instance checks can be customized via __instancecheck__ on the metaclass, which is how runtime_checkable Protocols answer isinstance.
# Modern registry: __init_subclass__, no metaclass needed
class Exporter:
registry: dict[str, type['Exporter']] = {}
def __init_subclass__(cls, *, fmt: str, **kwargs):
super().__init_subclass__(**kwargs)
if fmt in Exporter.registry:
raise TypeError(f'duplicate exporter for {fmt!r}')
Exporter.registry[fmt] = cls
class CsvExporter(Exporter, fmt='csv'): ...
class JsonExporter(Exporter, fmt='json'): ...
print(Exporter.registry) # {'csv': CsvExporter, 'json': JsonExporter}
# Metaclass: intercepts CLASS creation itself
class Validated(type):
def __new__(mcls, name, bases, ns):
for key, val in ns.items():
if callable(val) and key.startswith('handle_') and val.__doc__ is None:
raise TypeError(f'{name}.{key} must have a docstring')
return super().__new__(mcls, name, bases, ns)
class Webhook(metaclass=Validated):
def handle_payment(self):
"""Process payment events."""
Dynamic = type('Dynamic', (), {'answer': 42}) # classes are type() calls
print(Dynamic().answer)
Q104How does the import system work internally, and how do you diagnose and fix circular imports?
AdvancedModules
Answer
import machinery runs in phases. `import pkg.mod` first checks sys.modules (the cache; hits return immediately). On a miss, the finders in sys.meta_path are asked in order to locate the module: by default a builtin-module finder, a frozen-module finder, and PathFinder, which walks sys.path (script directory, PYTHONPATH, site-packages) using path-entry hooks and each package's __path__. A finder returns a ModuleSpec naming a loader; import then creates an EMPTY module object, inserts it into sys.modules FIRST, and only then executes the module's code against its namespace.
That ordering is the key to understanding circular imports: when A imports B and B imports A mid-way, B receives the partially-initialized A from sys.modules, whatever A defined BEFORE its `import B` line exists, everything after does not yet, producing 'ImportError: cannot import name X from partially initialized module A (most likely due to a circular import)', or an AttributeError later. Fixes in preference order: restructure so shared names move to a third module both import (cycles usually reveal a missing layering, models importing services importing models); import the MODULE rather than names (`import a` then a.f() at call time resolves after initialization, where `from a import f` binds at import time and fails); defer the import into the function that needs it (also the standard trick for heavy optional dependencies and CLI startup latency); and for type-only cycles, the typing.TYPE_CHECKING guard plus string annotations (or PEP 649 deferred annotations in 3.14, which lazily evaluate annotations and defuse most annotation-driven cycles natively). Diagnostics: `python -X importtime -c 'import app'` prints a cumulative import-time tree (the tool for slow-start services), and `python -v` traces imports. Also worth knowing: __init__.py executes on package import and should stay light; namespace packages (PEP 420) need no __init__.py; importlib.import_module does dynamic imports; importlib.reload re-executes in place; and Python 3.15's explicit `lazy import` statement (PEP 810) exists because slow imports cause real production pain in CLIs and serverless cold starts; it is also a clean fix for some import cycles, since the import runs on first use.
# a.py
# from b import make_b # <- at import time: cycle explodes here
import b # module-object import: safe
VALUE = 'defined-early'
def use_b():
return b.make_b() # name resolved at CALL time, after init
# b.py
from typing import TYPE_CHECKING
if TYPE_CHECKING: # type-only dependency: no runtime import
from a import Thing
def make_b():
from a import VALUE # deferred import: last-resort cycle breaker
return f'b sees {VALUE}'
def annotate(t: 'Thing') -> None: ... # string annotation
# Diagnostics:
# python -X importtime -c 'import app' 2> import.log
# sort -t'|' -k2 -n import.log | tail # slowest by cumulative time
Key Points
- sys.modules cache -> meta_path finders -> spec/loader -> exec module
- Module lands in sys.modules BEFORE executing: cycles see partial modules
- Fix order: restructure > import module not names > defer > TYPE_CHECKING
- python -X importtime is the tool for cold-start latency hunts
Q105When and how do you drop to native code: NumPy vectorization, Cython, ctypes/cffi, and Rust via PyO3?
AdvancedPerformance
Answer
The decision tree, in order of increasing commitment. First, verify the bottleneck is CPU-bound Python bytecode (profile; if it is I/O or the database, native code buys nothing). Second, exhaust library-level wins: NumPy/pandas vectorization moves loops into precompiled C operating on contiguous typed arrays, routinely 10-100x over per-element Python loops, because it eliminates per-item interpreter dispatch and boxing; the standard sin is iterating rows (df.iterrows()) instead of expressing column-wise operations, and 2026 candidates should also name polars, whose Rust engine and lazy query optimization have made it the default for new heavy-ETL work.
Numba deserves a mention here: a @numba.njit decorator JIT-compiles numeric Python functions via LLVM, often matching C for array math with zero build infrastructure, ideal for custom kernels NumPy cannot express. Third, binding an EXISTING native library: ctypes (stdlib, no compiler needed, fragile signatures declared by hand) or cffi (cleaner, parses C declarations) load shared libraries directly; for C++, pybind11 wraps classes with minimal boilerplate. Fourth, writing NEW native code: Cython compiles annotated Python-like code (cdef types, memoryviews) to C, is mature and incremental, and powers chunks of scikit-learn and pandas; the modern alternative is Rust with PyO3 and the maturin build tool, which is what the highest-profile recent Python infrastructure chose, ruff, uv, polars, and pydantic-core are all Rust cores with Python surfaces, because Rust brings memory safety and fearless parallelism to the extension layer.
Cross-cutting concerns an interviewer probes: extensions should release the GIL around long computations (Cython's `with nogil`, PyO3's allow_threads) so other threads make progress; crossing the boundary has serialization/conversion overhead, so design chunky interfaces (pass one array, not a million scalars); wheels must be built per-platform (cibuildwheel, manylinux) and, for the free-threaded era, audited for thread-safety and shipped against the t ABI. The closer that shows judgement: know when NOT to bother, an endpoint spending 40ms in Postgres and 2ms in Python does not need Rust.
import numpy as np
# Per-element Python loop: interpreter dispatch per item
def ema_slow(prices, alpha=0.1):
out, prev = [], prices[0]
for p in prices:
prev = alpha * p + (1 - alpha) * prev
out.append(prev)
return out
# Vectorized where possible; numba for genuinely sequential kernels:
# from numba import njit
# @njit(cache=True)
# def ema_fast(prices, alpha=0.1):
# out = np.empty_like(prices)
# out[0] = prices[0]
# for i in range(1, len(prices)):
# out[i] = alpha * prices[i] + (1 - alpha) * out[i - 1]
# return out
prices = np.random.rand(1_000_000)
returns = np.diff(prices) / prices[:-1] # vectorized: no loop
zscore = (returns - returns.mean()) / returns.std()
flagged = np.where(np.abs(zscore) > 4)[0] # anomaly indices
print(len(flagged))
# Rust route (PyO3 + maturin) is how ruff/uv/polars/pydantic-core ship:
# maturin develop --release # builds the extension into your venv
Q106How do you deploy a Python web service properly: gunicorn/uvicorn workers, worker sizing, and container hygiene?
AdvancedProduction
Answer
Never serve production traffic with `flask run` or `python manage.py runserver`: those are single-threaded dev servers without worker management. The standard shapes: for WSGI apps (Django, Flask), gunicorn forks a master plus N sync workers (processes), sized by the classic starting rule of (2 x cores) + 1, then tuned by measurement; add --threads for mixed workloads. For ASGI apps (FastAPI, Starlette, Django async), run uvicorn workers, either standalone `uvicorn app:app --workers 4` or gunicorn with -k the uvicorn-worker package's UvicornWorker for gunicorn's battle-tested process supervision; each worker is one event loop handling thousands of concurrent connections, so worker count tracks cores, not expected connections.
Gunicorn settings that map to real incidents: --timeout kills a hung sync worker (and must exceed your slowest legitimate request, but never be so large a stuck worker occupies a slot for minutes); --graceful-timeout bounds shutdown; --max-requests N with --max-requests-jitter recycles workers periodically, the blunt but effective mitigation for slow memory leaks; preload_app=True imports the application ONCE in the master before forking, saving memory via copy-on-write (pair with gc.freeze()) at the cost of sharing nothing that must be per-process, connection pools created pre-fork and shared across forks are the classic corruption bug, so create connections in post_fork hooks or lazily per worker. In front, an ingress/nginx/ALB terminates TLS, buffers slow clients (protecting sync workers from slowloris-style occupancy), and enforces body-size limits. Container hygiene: base on python:3.14-slim, multi-stage builds (uv sync in a builder, copy the venv), run as a non-root USER, set PYTHONUNBUFFERED=1 so logs stream to the collector rather than buffer, PYTHONDONTWRITEBYTECODE=1 in containers, pin the image digest, and add a HEALTHCHECK or k8s readiness probe hitting a real dependency-checking endpoint.
Handle SIGTERM: k8s sends it, then SIGKILL after the grace period, so your process must stop intake and drain within it (gunicorn does this; a bare python worker script needs its own signal handler). One process per container is the norm, letting the orchestrator own scaling and restarts rather than nesting supervisors.
# gunicorn.conf.py
import multiprocessing
bind = '0.0.0.0:8000'
workers = multiprocessing.cpu_count() # async workers: one event loop per core (sync workers: 2*cores+1), then measure
worker_class = 'uvicorn_worker.UvicornWorker' # ASGI (FastAPI); from the uvicorn-worker package, uvicorn.workers is deprecated
timeout = 30
graceful_timeout = 20
max_requests = 5000 # recycle: blunts slow leaks
max_requests_jitter = 500 # avoid synchronized restarts
preload_app = True # copy-on-write memory savings
def post_fork(server, worker):
# per-worker resources: NEVER share pooled sockets across forks
from app.db import init_pool
init_pool()
# Dockerfile (multi-stage, non-root)
# FROM python:3.14-slim AS builder
# COPY pyproject.toml uv.lock ./
# RUN pip install uv && uv sync --frozen --no-dev
# FROM python:3.14-slim
# ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# COPY --from=builder /.venv /.venv
# COPY src/ /app/src
# USER 1000
# CMD ["/.venv/bin/gunicorn", "-c", "gunicorn.conf.py", "app.main:app"] # exec form: double quotes
Key Points
- WSGI: gunicorn process workers; ASGI: uvicorn event-loop workers
- preload_app + COW saves memory; create pools post-fork, never pre-fork
- max-requests recycling is the pragmatic leak mitigation
- SIGTERM draining must finish inside the k8s grace period
Q107From 3.11 to 3.15, what changed in CPython performance (specializing interpreter, JIT, tail-call interpreter, lazy imports), and what should a senior engineer actually do about it?
AdvancedVersions
Answer
The Faster CPython effort restructured the interpreter across releases. 3.11 delivered the largest single jump (roughly 1.25x average on pyperformance) via PEP 659's specializing adaptive interpreter: hot bytecodes observe their operands and quicken into specialized forms, BINARY_OP becomes a float-add fast path, LOAD_ATTR becomes a version-checked inline cache hit, plus cheaper frames and zero-cost-when-not-raised exceptions. 3.12 refined specialization (comprehension inlining) and split the GIL per subinterpreter at the C level. 3.13 introduced two experimental builds: free-threaded (discussed separately) and the copy-and-patch JIT (PEP 744), which stitches precompiled machine-code templates for hot micro-op traces; it ships disabled or experimental in official binaries, and its wins remain modest while the infrastructure matures, so the honest line is 'a foundation, not yet a Node/V8 moment'. 3.14 added a tail-call-based interpreter dispatch mode (build option using compiler tail calls between opcode handlers) yielding measurable interpreter speedups on supported toolchains, made free-threading officially supported, and landed deferred annotations (PEP 649/749), which cut import-time annotation costs and defused the annotations-and-circular-import papercut class. (3.14.0 to 3.14.4 also shipped an incremental garbage collector, but 3.14.5 reverted to the generational GC from 3.13 after memory-pressure reports, a useful example of why you read the changelog of patch releases.) 3.15 (October 2026) added explicit lazy imports (PEP 810), which cut startup time for large applications and CLIs, a significantly upgraded but still experimental JIT, frame pointers enabled by default for better profiler and observability support (PEP 831), a new profiling package with the Tachyon sampling profiler (PEP 799), and the official Windows binaries now use the tail-calling interpreter. What a senior should DO with this: first, treat interpreter upgrades as a real performance lever, moving 3.10 to 3.14/3.15 often buys double-digit percentage wins for free, cheaper than any refactor, so keep upgrade paths unblocked (the dead-battery removals in 3.13 and old deprecations are the usual blockers; run CI on the next version continuously); second, re-benchmark folk wisdom, several classic micro-optimizations (hoisting attribute lookups, avoiding try/except overhead) shrank or vanished under specialization, so profile on YOUR target version; third, watch that C-extension-heavy workloads benefit least (time is already native) while pure-Python request handling benefits most; fourth, for the JIT and free-threading, adopt by measurement on canaries, not by headline. Being able to sketch this arc, and to say precisely which parts are default versus opt-in builds, is exactly the currency of staff-level Python interviews in 2026.
Key Points
- 3.11: PEP 659 specializing interpreter, ~1.25x average, cheap exceptions
- 3.13: experimental copy-and-patch JIT (PEP 744) + free-threaded build
- 3.14: tail-call interpreter option, supported free-threading, PEP 649 deferred annotations; incremental GC reverted in 3.14.5
- 3.15: lazy imports (PEP 810), upgraded JIT, frame pointers on by default, Tachyon profiler
- Action: upgrade as a perf lever, re-profile folk optimizations, canary the opt-in builds
Q108How should a fresher prepare for a Python interview in 30 days?
BasicPreparation
Answer
A fresher Python interview has three layers: an online test (MCQs plus one or two coding problems), a technical round on fundamentals and your projects, and an HR round. Prepare in that order of weight, and prepare by doing, not reading.
Week 1, fundamentals: data types and mutability, list versus tuple versus set versus dict and their complexities, slicing, comprehensions, functions with *args and **kwargs, scope, exceptions, file handling, and the output questions on this page. Type every snippet and predict the output before running it. Week 2, OOP and the standard library: classes, inheritance, super, dunder methods, decorators, generators, context managers, and collections, itertools, json and pathlib. Week 3, coding practice: 40 to 60 problems on strings, lists, dicts, two pointers, stacks, sorting and basic recursion, timed, on a HackerRank-style editor with no autocomplete, because that is what the test looks like. Week 4, projects and mock interviews: rebuild one project from scratch (a REST API with FastAPI or Flask, or a data analysis with pandas), be able to explain every design decision and every bug you fixed, then do at least three timed mock interviews out loud.
Daily rhythm that works: 60 minutes of concepts, 60 minutes of problems, 15 minutes of revision cards. Keep an error log of every output you predicted wrong; re-run that log twice in the final week. Freshers who do this consistently tend to do better in the technical round than those who memorise 200 questions.
Key Points
- Week 1 fundamentals and output questions; Week 2 OOP and standard library
- Week 3: 40 to 60 timed coding problems in a plain editor; Week 4: project deep-dive and mock interviews
- Keep an error log of wrong predictions and re-run it before the interview
- Practise explaining answers out loud; the technical round is a conversation, not a quiz
Q109What do Indian service companies and product companies ask differently in Python interviews?
BasicPreparation
Answer
Service companies (TCS, Infosys, Wipro, HCLTech, Cognizant, Capgemini, Accenture) hire at volume, so their rounds are standardised: an aptitude plus coding assessment (the TCS NQT coding section lets you pick Python; check the notification for your drive), then a technical interview that leans on definitions, output questions, OOP concepts, basic SQL and your final-year project, and an HR round on relocation, shifts, bonds and notice period. Breadth matters: they want to know you can be trained on any stack, so one clear sentence per concept beats depth on one topic. Questions like 'list versus tuple', 'what is a decorator', 'explain the GIL' and 'reverse a string' are very likely.
Product companies and funded startups (Zerodha, Razorpay, Flipkart, Swiggy, Meesho, CRED, PhonePe, Atlassian and Google India among them) hire fewer people per role and test depth and judgement: a 60 to 90 minute DSA round in Python where complexity and edge cases are scored, a machine-coding or take-home round (build a small service with tests), a language round that goes one level below the textbook (why is the mutable default shared, what does the GIL actually protect, when is asyncio the wrong tool), and for experienced hires a system design round. They expect idiomatic Python: comprehensions, generators, dataclasses, type hints, pytest, and reading the standard library before writing your own.
Prepare for both with the same core, then add breadth for services (SQL, OOP vocabulary, aptitude speed) and depth for product companies (DSA in Python, one real project with tests, internals of three or four topics you can discuss for ten minutes each).
Key Points
- Services: assessment, definitions, output questions, OOP, basic SQL, project, HR; breadth wins
- Product: timed DSA in Python, machine coding or take-home, internals-level language round, design for seniors
- Same core for both; add aptitude and SQL for services, depth and tests for product
- Idiomatic Python (comprehensions, generators, dataclasses, typing, pytest) is expected at product companies
Q110What does a Python interview test or online coding round look like, and how do you clear it?
BasicPreparation
Answer
Most Python hiring starts with an online assessment before any human talks to you. The common formats are: a timed MCQ section on Python output, data types and OOP; one to three coding problems on a HackerRank, Codility, HackerEarth or company-owned editor with hidden test cases; and, for data roles, a SQL or pandas section. Campus drives (TCS NQT, Infosys, Wipro, Accenture) add aptitude and reasoning; product companies usually send a single 60 to 90 minute coding test with two or three problems.
Clear it by matching how it is scored. Hidden tests mean correctness on edge cases (empty input, one element, duplicates, negatives, very large n) counts more than elegance, so read constraints first and pick the algorithm by them: n up to 10 to the power 5 rules out O(n squared). Read input exactly as specified (input().split(), sys.stdin for large inputs) and print exactly the expected format; a stray space fails tests. Practise in a plain editor with no autocomplete and no AI assistant, since that is what you get in the test.
Time management: solve the easiest problem fully first, submit, then move on; partial scores exist on most platforms. Comment your approach in two lines so a human reviewer sees your reasoning if the code is reviewed later. And know your standard library: Counter, defaultdict, deque, heapq, bisect, itertools and sorted with key are the difference between twenty lines and five.
# Typical online-test scaffold: fast input, exact output, edge cases first.
import sys
from collections import Counter
def solve(nums, k):
# k most frequent numbers, ties by smaller number first
if not nums or k <= 0:
return []
counts = Counter(nums)
return [n for n, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:k]]
def main():
data = sys.stdin.read().split()
if not data:
return
n, k = int(data[0]), int(data[1])
nums = list(map(int, data[2:2 + n]))
print(' '.join(map(str, solve(nums, k))))
if __name__ == '__main__':
main()
# Local check without stdin:
# print(solve([1, 1, 2, 2, 3], 2)) -> [1, 2]
Key Points
- Formats: MCQs on output and types, 1 to 3 coding problems with hidden tests, SQL or pandas for data roles
- Read constraints first; choose the algorithm by n; handle edge cases before elegance
- Exact input parsing and output format; sys.stdin for large inputs
- Easiest problem first, submit early, use Counter/deque/heapq/bisect fluently
Q111Is Python better than Java?
BasicPreparation
Answer
Neither is better in general; they win in different places. Python is faster to write, dominates data science, machine learning, automation and scripting, and is the usual choice for startups shipping APIs quickly. Java runs faster, has stronger static typing and tooling for very large codebases, and dominates enterprise backends, banking systems and Android.
For an interview, answer with criteria rather than a verdict. Execution speed: Java's JIT compiled bytecode is typically several times faster than CPython for CPU-bound code, which is why Python leans on C-backed libraries such as NumPy. Concurrency: Java threads run in parallel on all cores; CPython threads share the GIL, so Python uses multiprocessing, asyncio or the free-threaded build. Typing: Java checks types at compile time; Python is dynamically typed with optional hints checked by mypy or pyright. Ecosystem: Python for data, ML, scripting, DevOps glue; Java for Spring-based enterprise services, JVM big data tooling (Hadoop, Kafka; Spark itself is written in Scala) and Android.
For the Indian job market, both have deep demand. Service companies' enterprise projects create large Java demand, while Python demand is concentrated in data, AI, automation and startup backends. If the interviewer asks which you would choose for a new project, pick by the team's strengths, the performance envelope and the libraries you need, and say so.
Key Points
- Python: developer speed, data and ML, automation, startup APIs
- Java: runtime speed, static typing, enterprise backends, Android
- Concurrency differs: Java threads are parallel; CPython has the GIL
- Answer with criteria (speed, typing, ecosystem, team), not a verdict
Q112How do I start learning Python?
BasicPreparation
Answer
Install the latest Python 3 from python.org, use the official tutorial for the first two weeks, and write code every day in a plain editor or VS Code. Learn in this order: variables and data types, control flow, functions, lists and dicts, files and exceptions, then classes, modules and the standard library. Build a small project by week three.
Projects teach more than courses. Good first ones: a command-line expense tracker that saves to a CSV, a script that renames or organises files, a web scraper with requests and BeautifulSoup, a to-do API with Flask or FastAPI, or a data analysis of a public dataset with pandas. Each forces you through input, data structures, errors and the standard library.
Free resources that are reliable: the official Python tutorial and documentation, Automate the Boring Stuff with Python (free online), the Real Python articles, and practice platforms such as HackerRank, LeetCode and Codewars for daily problems. Avoid tutorial hopping: pick one resource, finish it, then build. When you can write a 100-line program without looking at documentation for syntax, start the interview questions on this page from the basic section and the coding section, and begin reading other people's code on GitHub.
Key Points
- Install Python 3, follow the official tutorial, code daily in a plain editor
- Order: types, control flow, functions, lists and dicts, files, exceptions, classes, modules
- Build small projects from week three: CLI tool, scraper, small API, pandas analysis
- One resource at a time, then practice platforms and reading real code
Q113What are the best questions to ask an interviewer at the end of a Python interview?
BasicPreparation
Answer
Ask about the work, the code and the team, in that order. Good questions: what does the Python codebase look like (version, frameworks, typing, tests), what problem will I work on in the first three months, how is code reviewed and deployed, and what separates people who do well here from those who struggle.
Specific, technical questions signal that you listened and that you are evaluating them too. Examples that land well: 'Which Python version are you on, and is there an upgrade planned?' 'Do you use type hints and a checker like mypy or pyright in CI?' 'How do you handle background jobs: Celery, a queue, or asyncio?' 'What does your test suite look like, and how long does CI take?' 'What was the last production incident, and what changed because of it?' For data roles: 'What is the data stack between raw data and the model or dashboard?'
Avoid asking about salary, leave policy or work-from-home in the technical round; those belong to HR. Avoid questions whose answer is on the company website. Keep it to two or three questions, listen to the answers, and ask one follow-up; that short conversation is often the interviewer's last impression of you.
Key Points
- Ask about the codebase, the first-quarter problem, review and deploy process, and what success looks like
- Technical specifics (Python version, typing, tests, background jobs, incidents) show you listened
- Save salary, leave and WFH for the HR round
- Two or three questions, then a genuine follow-up
Frequently Asked Questions
What salary can a Python developer expect in India in 2026?
There is no single number; pay depends on the lane and the level more than on Python itself. Service-company fresher packages are the lowest, product companies and funded startups pay well above them at entry level, mid-level backend roles that combine Python with SQL, a framework and cloud sit higher, and data engineering, ML and LLM-infrastructure roles currently price at the top, with global capability centres paying at the upper end of each band. Check current ranges on GoodSpace job listings and the employer's own disclosed bands before you negotiate; async expertise, typing discipline and production debugging stories move offers more than certificate counts.
How long does it take to prepare for a Python interview?
From an existing working knowledge, 3-4 weeks of structured prep covers most mid-level loops: one week on language internals (mutability, closures, decorators, generators, the GIL), one on the concurrency triad (threading/multiprocessing/asyncio) and typing, one on testing/tooling (pytest, mocking, uv, packaging), and one doing mock interviews plus DSA refreshers in Python (heapq, collections, comprehension fluency). Freshers starting from scratch should budget 2 to 3 months including DSA; with working Python knowledge, the 30-day plan on this page is enough for the language round. The highest-yield habit is writing and running the gotcha examples yourself (mutable defaults, late binding, is vs ==, dict iteration mutation) because interviewers push one level past the memorized answer, and only people who ran the code survive the follow-up. Senior candidates should spend a full week on production stories: a leak you found with tracemalloc beats any textbook answer.
What is the difference between fresher and experienced Python interview expectations?
Fresher rounds test language correctness and DSA in Python: list/dict/set operations and complexities, comprehensions, exception handling, OOP basics, plus one or two gotchas (mutable defaults is nearly guaranteed). Nobody expects production war stories. At 3-5 years, the bar shifts to judgment: choosing threads versus asyncio for a given workload, writing testable code with fixtures and mocks, typing a real API, packaging with pyproject.toml, and explaining at least one debugging story with a profiler or tracemalloc. At senior/staff level, interviews become systems conversations where Python is the medium: GIL and free-threading implications, memory model, import-time costs at scale, deployment topology (gunicorn/uvicorn sizing), and the trade-offs of dropping to Rust or C. A senior candidate who only answers at the syntax level fails even with perfect answers; depth on two or three internals topics matters more than coverage of all.
Is Python still worth learning in 2026 given the AI coding-assistant wave?
Yes, and arguably more than before, precisely because of that wave. Python is the default language of AI infrastructure itself: model serving, agent frameworks, evaluation pipelines, and data tooling are overwhelmingly Python, so demand from the fastest-growing engineering segment lands on Python skills. Assistants have changed what is valued, not whether the language matters: syntax recall is cheap now, while the ability to judge generated code (spot the mutable default, the blocking call in async code, the unbounded cache) is exactly what interviews increasingly test. The language is also in its strongest technical shape in a decade: free-threading is real, the toolchain (uv, ruff) removed the historical packaging pain, and performance improves every release. The risk to price in is that entry-level CRUD-only skills are commoditizing; pair Python with a domain (data platforms, ML systems, fintech backends) rather than stopping at the language.
Should I learn Python or JavaScript/Go/Java for backend roles in India?
They optimize different portfolios. Python gives the widest surface: backend (Django/FastAPI), data engineering, ML, scripting, and QA automation all hire against it, so it maximizes optionality, and it is the only serious choice if data/AI is a possible direction. JavaScript/TypeScript wins if you want full-stack roles, one language across React and Node. Go is the infrastructure specialist's pick: Kubernetes-adjacent tooling, high-concurrency network services, and platform teams (including some Indian fintechs) use it, but the job pool is narrower and mostly mid-senior. Java remains the volume leader at enterprises and banks, with Spring Boot dominating large-system hiring. A pragmatic Indian-market strategy: Python or Java as the primary depending on target companies, TypeScript as the second, and Go picked up on the job when a platform team needs it. Switching between Python and Go later is far easier than building the first language's depth, so choose by target roles, not by benchmark charts.
Do Python certifications matter, or should I build projects instead?
Certifications carry little weight at Indian product companies; product-company interviewers rarely shortlist on a Python certificate, and services companies mostly treat them as HR checkboxes. What moves shortlists: a GitHub with two or three substantial projects (a deployed FastAPI service with tests and CI, a data pipeline with real error handling, an open-source contribution to a library you use), because they generate interview conversation material you control. If you want structured learning anyway, cloud certifications (AWS) signal more than language ones since they pair Python with deployability. The exception: for complete freshers from non-CS backgrounds, one recognized credential can help clear resume screens at mass recruiters. Ratio to aim for: 80 percent building and debugging real things, 20 percent structured coursework, and write up what broke and how you fixed it, that narrative is exactly what mid-level interviews reward.
How many Python questions should I prepare for a placement drive?
Aim to master roughly 40 to 60 conceptual questions (the basic section of this page), the output questions, and a solid set of coding problems on strings, lists, dictionaries, sorting and basic recursion. Quality of understanding matters more than the raw count, because interviewers push one level past the memorised answer.
Is Python enough to get a software job in India?
Python alone opens doors in data science, backend, automation and QA roles, but pairing it with SQL, one framework (Django, FastAPI or Flask), Git, and strong problem-solving makes you far more competitive at both service and product companies. Most Python job descriptions in India list SQL and one web or data framework alongside the language.
What is the most common tricky Python question?
The mutable default argument trap and the difference between is and == are the two most common, because they reveal whether you understand how Python names, objects and references behave. Late-binding closures in loops and [[0] * 3] * 3 are the next two; all four are in the output questions above.
Should I learn Python 2 or Python 3?
Learn Python 3; Python 2 reached end of life on 1 January 2020 and no current release supports it. You should still recognise a few Python 2 differences, such as print as a statement, integer division and xrange, because legacy code and old question banks still mention them.
How do I stop freezing during live coding rounds?
Practise under time pressure and speak while you code: restate the problem, list edge cases, say the approach and its complexity, then write. Rehearsing with timed mock interviews trains you to narrate your thinking, which keeps you calm and shows the interviewer how you reason even when the final answer is not perfect.
Introduction
This page has 113 Python interview questions with answers, grouped the way interviews actually run: basic questions that every fresher round opens with (data types, list versus tuple, is versus ==, *args and **kwargs, copies, mutable defaults), coding and output questions of the kind TCS, Infosys and Wipro online tests use, intermediate topics that decide mid-level offers at product companies (decorators, generators, the GIL, asyncio, typing, testing, packaging, pandas and NumPy basics, Django and Flask basics, automation), and advanced internals for senior rounds (memory management, free-threading, descriptors, metaclasses, deployment). The difficulty tabs and the search box at the top of the page filter the list.
Python interviews in 2026 look different from five years ago. The language moved fast: Python 3.13 shipped an experimental free-threaded build and a new REPL, 3.14 made free-threading officially supported, and 3.15 (October 2026) added lazy imports and a stable ABI for free-threaded builds, while the toolchain consolidated around uv, ruff and pyright. The AI wave made Python the connective tissue of nearly every ML, data and agent-infrastructure team, so demand broadened from Django CRUD shops to LLM platform roles. Interviewers now assume you can write working code with an assistant; what they test is whether you understand what the interpreter actually does underneath, and whether you can spot the bug in generated code.
In India the bar differs by employer. Service companies run fundamentals-heavy rounds with output questions, OOP vocabulary and one or two coding problems, and reward breadth. Backend roles at product companies and fintechs tend to probe asyncio, typing, packaging and production failure modes. Data and ML roles lean on the object model, memory behaviour, NumPy and pandas, and profiling. The last block of questions on this page covers a 30-day fresher preparation plan, what service and product companies ask differently, how online coding tests are scored, and the questions to ask your interviewer. Each answer explains how CPython really behaves, the production gotcha attached to the concept, and what a strong interviewer follows up with; code examples use current syntax because writing legacy idioms in 2026 is itself a negative signal.
Ready to practice Python interviews?
Don't just read, practice these Python questions live with an AI interviewer that asks follow-ups and scores your answers.