Python Interview Questions and Answers

Last updated:

Check out 60 of the most common Python interview questions, then take an AI-powered practice interview

60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

What actually happens when you run `python app.py`? Explain bytecode and __pycache__.

BasicInterpreter

Answer

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.13) 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
Q2

Which built-in types are mutable and which are immutable, and why does it matter for function arguments?

BasicData Model

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
💡 Pro Tip: When asked about pass-by-value versus pass-by-reference, say Python is 'pass-by-object-reference' (call-by-sharing) and demonstrate with the append-versus-rebind example. That phrase plus a working example ends the debate.
Q3

Why 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`. Python 3.10+ lets you write that fallback as `items = items if items is not None else []` or with a simple if block; all 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: linters like ruff flag this as B006 (mutable-argument-default) out of the box, 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 catches this automatically
Q4

Explain `is` versus `==`, and why `a is b` can be True for small integers but False for large ones.

BasicData Model

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 True`, `x is NotImplemented`, 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
💡 Pro Tip: Never let the discussion end at 'is checks identity'. Volunteer that interning behavior differs between REPL and script: that one sentence is what separates memorized answers from understood ones.
Q5

List comprehension versus generator expression: when does the difference actually matter?

BasicIteration

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
Q6

How does a Python dict work internally, and what guarantees does it make about ordering?

BasicData Model

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
Q7

What is the difference between copy.copy() and copy.deepcopy(), and when does a shallow copy bite you?

BasicData Model

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
Q8

Explain *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.7), 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.goodspace.ai', 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
Q9

What 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
Q10

How does slicing work in Python, including negative indices, steps, and slice assignment?

BasicSequences

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'
Q11

Walk 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 direct cause of the above exception was', 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
Q12

How do context managers work, and how do you write one with __enter__/__exit__ and with contextlib?

BasicContext Managers

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]
Q13

What is the iterator protocol? Explain __iter__, __next__, and StopIteration.

BasicIteration

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(sock.recv, 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'
💡 Pro Tip: If asked 'why can I loop a list twice but a generator once', answer in protocol terms: the list's __iter__ makes a new iterator per loop; the generator IS the iterator. That sentence shows you know the protocol, not just the symptom.
Q14

What do enumerate() and zip() do, and what does zip(strict=True) fix?

BasicIteration

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
Q15

Explain Python's LEGB scoping rules and the difference between global and nonlocal.

BasicScoping

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
Q16

What 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, where the default spawn start method re-imports 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`
    print(mean([int(a) for a in sys.argv[1:]]))

# 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]]))
Q17

How 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 --frozen` so the container build fails loudly when the lock is stale rather than drifting silently.

# 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 --frozen --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 --frozen in CI/Docker fails loudly on stale locks
Q18

What 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 is slated to make UTF-8 the default (PEP 686), and you can opt in today with the -X utf8 flag or PYTHONUTF8=1. 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))
Q19

What are sets and frozensets, and when do they beat lists in real code?

BasicData Model

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
💡 Pro Tip: When an interviewer shows you `if x in some_list` inside a loop, the expected observation is quadratic behavior and the fix is one line: hoist a set() conversion out of the loop. This exact refactor shows up in live-coding rounds constantly.
Q20

sorted() versus list.sort(): how do key functions, stability, and reverse sorting actually work?

BasicSequences

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')))
Q21

How does truthiness work in Python, and when is `if not x` a bug compared to `if x is None`?

BasicData Model

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, midnight as a time value, 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 3.8+ code sometimes 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
Q22

What 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
Q23

Show the unpacking idioms: starred assignment, swapping, merging dicts, and unpacking in function calls.

BasicSyntax

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
Q24

What changed in Python 3.12, 3.13, and 3.14 that an interviewer expects you to know?

BasicVersions

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 smtpd, 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). Sensible framing for interviews: production default in early 2026 is typically 3.12 or 3.13, with 3.14 adoption growing; free-threading is real but most library ecosystems are still validating compatibility, so answer 'supported, separate build, ecosystem 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)
  • Say 'GIL-free is a separate supported build', never 'the GIL is gone'
💡 Pro Tip: If you claim a version feature in an interview, be ready to name the PEP or demo the syntax. One accurate sentence about PEP 703's build status is worth more than five vague ones about 'Python removing the GIL'.
Q25

Write 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 retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]` with the 3.12 syntax, 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[**P, R](times: int = 3, delay: float = 0.2):
    def decorator(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
Q26

How 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]
Q27

What exactly is the GIL, what does it protect, and what is the status of free-threaded Python?

IntermediateConcurrency

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). 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 (not in Jython or GraalPy), 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
💡 Pro Tip: Never say 'Python cannot do parallelism'. Say: processes for CPU, threads for I/O, asyncio for massive I/O concurrency, and the free-threaded build where measured. That taxonomy is the expected answer shape in 2026.
Q28

threading 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/Windows, and the default on Linux too as of Python 3.14, which is a migration gotcha because spawn re-imports your module and requires the __main__ guard), and forkserver.

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 Django monolith at Flipkart scale 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://goodspace.ai')

    # 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())
Q29

How 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.goodspace.ai/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):
#         ...
Q30

Explain 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.goodspace.ai') 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
Q31

How do dataclasses work: field(), default_factory, frozen, slots, and when do you reach for pydantic instead?

IntermediateClasses

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: list[str] = field(default_factory=list)
    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({job, senior})             # hashable: frozen+eq
# 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
Q32

How 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
💡 Pro Tip: In live rounds, typing your solution as you write it (parameters, returns, TypedDict for any dict payload) is a strong signal at product companies; several Bangalore fintechs explicitly grade on it.
Q33

How does structural pattern matching (match/case) work, and what is the capture-versus-constant pitfall?

IntermediateSyntax

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. Checkers flag 'name capture makes remaining patterns unreachable'. 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!')    # checker: remaining patterns unreachable
# Fix: use a dotted/qualified constant, e.g. case Commands.EXPECTED
Q34

How 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) historically 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
Q35

Explain __repr__ versus __str__, and the __eq__/__hash__ contract you must maintain together.

IntermediateObject Model

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
Q36

How do @property and setters work, and when do you use cached_property?

IntermediateObject Model

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
Q37

How does Python's MRO work with multiple inheritance, and what does super() actually do?

IntermediateObject Model

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
Q38

@classmethod versus @staticmethod versus instance methods: what does each receive, and what is the alternative-constructor pattern?

IntermediateObject Model

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
Q39

How does modern Python packaging work: pyproject.toml, build backends, editable installs, and the src layout?

IntermediatePackaging

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
Q40

How 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.12 vs 3.13) 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 at fintechs.

CI/CD rules: install from the lock in frozen mode, `uv sync --frozen` fails the build if pyproject.toml and uv.lock disagree, rather than silently re-resolving, 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 --frozen --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.13

# Add + lock (uv workflow)
uv add 'httpx>=0.27,<1'
uv lock                       # rewrites uv.lock with hashes
uv sync --frozen              # 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.13-slim
# COPY pyproject.toml uv.lock ./
# RUN pip install uv && uv sync --frozen --no-dev
# COPY src/ ./src/
Q41

Why 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
Q42

How 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):
#       return send_sms(user.phone, msg)

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']
Q43

How 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
Q44

How 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.
Q45

Which 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))
Q46

Why 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')
Q47

How do you serialize Python objects to JSON, what breaks by default, and why is pickle dangerous?

IntermediateSerialization

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
Q48

How 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 (3.12+ supports `with`), 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: 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, sorted by cumulative time
python -m cProfile -s cumtime -o out.prof pipeline.py
# 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)
💡 Pro Tip: In system-design-flavored rounds, saying 'I would attach py-spy to the stuck pod and read the dump before guessing' is a stronger answer than naming any algorithmic optimization. Interviewers reward diagnosis-first instincts.
Q49

Explain CPython's memory management: reference counting, the cyclic GC, weakref, and immortal objects.

AdvancedInternals

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)
Q50

Free-threaded CPython (PEP 703) and subinterpreters (PEP 734): what changed mechanically, and when would you actually use them?

AdvancedConcurrency

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.

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
💡 Pro Tip: The differentiating sentence in interviews: 'the GIL protected CPython's internals, never my invariants'. Then give the lost-update example (compound read-modify-write on a shared dict) that needs a lock under BOTH builds.
Q51

How 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'
Q52

What 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 (which is how Python makes `list[int]` work through __class_getitem__-adjacent machinery, and how enums make iteration over the class possible). 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)
Q53

How does the import system work internally, and how do you diagnose and fix circular imports?

AdvancedInternals

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 3.14's incremental work on lazy importing reflects how much production pain slow imports cause in serverless cold starts.

# 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 -k2 -n import.log | tail    # slowest imports

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
Q54

Advanced 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
    await asyncio.wait(running_tasks, timeout=25)

asyncio.run(main())
Q55

When 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 GoodSpace-relevant closer: 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
Q56

What does __slots__ actually change about instance layout, and when is the optimization worth it?

AdvancedInternals

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'

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
Q57

Why 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; run two threads doing a million increments each and the total lands visibly short of two million. 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; threading's faulthandler and py-spy dump find the deadlocked stacks.

import threading, queue

count = 0
lock = threading.Lock()

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=unsafe) for _ in range(2)]
[t.start() for t in ts]; [t.join() for t in ts]
print(count)   # < 2_000_000 on most runs: lost updates

# Producer-consumer: share the queue, share nothing else
jobs: queue.Queue[str] = queue.Queue(maxsize=100)   # backpressure

def worker():
    while True:
        url = jobs.get()
        if url is None:        # poison pill shutdown
            break
        process(url)
        jobs.task_done()

workers = [threading.Thread(target=worker, daemon=True) for _ in range(4)]
[w.start() for w in workers]
for u in urls:
    jobs.put(u)                # blocks when full: bounded memory
jobs.join()                    # wait until every task_done
Q58

How 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 uvicorn.workers.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.13-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() * 2 + 1   # starting point, then measure
worker_class = 'uvicorn.workers.UvicornWorker'  # ASGI (FastAPI)
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.13-slim AS builder
# COPY pyproject.toml uv.lock ./
# RUN pip install uv && uv sync --frozen --no-dev
# FROM python:3.13-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']

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
Q59

A Python service's memory climbs steadily in production until the pod is OOM-killed. How do you investigate and what are the usual causes?

AdvancedProduction

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
Q60

From 3.11 to 3.14, what changed in CPython performance (specializing interpreter, JIT, tail-call interpreter), 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; incremental garbage collection also reduced pause spikes on large heaps. What a senior should DO with this: first, treat interpreter upgrades as a real performance lever, moving 3.10 to 3.13/3.14 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
  • Action: upgrade as a perf lever, re-profile folk optimizations, canary the opt-in builds
💡 Pro Tip: Precision beats enthusiasm here: 'the JIT is experimental and off by default; the free-threaded build is supported but separate' is the sentence that shows you run Python in production rather than read release headlines.

Companies Hiring Python

Zerodha
Flipkart
Razorpay
Swiggy
TCS
Infosys
Google
Amazon

Salary Insights

Average in India
₹6-22 LPA

Frequently Asked Questions

What salary can a Python developer expect in India in 2026?

The realistic band is ₹6-22 LPA depending on lane and level. Freshers at services companies (TCS, Infosys, Wipro) start around ₹3.5-7 LPA, while product-company freshers clear ₹10-18 LPA. Mid-level backend engineers (3-6 years) with Django/FastAPI plus solid SQL and AWS sit at ₹15-30 LPA at companies like Razorpay, Swiggy, and Flipkart. Data engineering and ML lanes price higher: ₹20-45 LPA at 4-8 years is common in Bangalore fintech, and LLM-infrastructure roles (vector stores, inference serving, agent frameworks) currently command the largest premiums. Zerodha, known for small teams, and global capability centers (Google, Amazon, Walmart Labs) pay at the top of every band. 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 should budget 2-3 months including DSA. 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 several Indian fintechs) love 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; no interviewer at Razorpay or Flipkart shortlists 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.

Introduction

Python interviews in 2026 look very different from the ones five years ago. The language itself has moved fast: Python 3.13 shipped an experimental free-threaded build and a new REPL, Python 3.14 made free-threading officially supported, and the toolchain has consolidated around uv, ruff, and pyright. At the same time, the AI wave means Python is the connective tissue of nearly every ML, data, and agent-infrastructure team, so demand has broadened from Django CRUD shops to LLM platform roles. Interviewers now assume you can write working code with a copilot; what they test is whether you understand what the interpreter actually does underneath.

In India, Python hiring splits into three lanes with different bars. Backend roles at companies like Zerodha, Razorpay, and Swiggy probe asyncio, typing, packaging, and production failure modes. Data and ML roles at Flipkart, Google, and Amazon India lean on the object model, memory behavior, NumPy interop, and profiling. Services companies like TCS and Infosys still run fundamentals-heavy rounds: mutable defaults, copy semantics, comprehensions, and exception handling. The common thread is that pure syntax questions are disappearing; every serious round now includes at least one gotcha (late-binding closures, is versus ==, GIL behavior) designed to separate people who have shipped Python from people who have only read about it.

This guide contains 60 questions arranged from basic through advanced, so you can calibrate against the level you are interviewing for. 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 modern syntax (3.10+ pattern matching, 3.12 type parameters, TaskGroup-based asyncio) because writing legacy idioms in 2026 is itself a negative signal. Work through the basic block in a day or two, then spend most of your prep time on the intermediate section: that is where mid-level offers at product companies are actually decided.

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.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview