The fifty Python questions that actually get asked
The data model, closures and decorators, the MRO, comprehensions and complexity, exceptions and context managers, the GIL and asyncio, then packaging and typing — answered with the mechanism underneath, not just the rule.
Python interviews split cleanly in two. The first half is the data model — names, objects, mutability, and what a dunder method hooks into — and almost every "why does this print that?" puzzle lives there. The second half is the runtime: the GIL, what actually runs in parallel, and how the async story fits. The middle sections below are the connective tissue: the idioms a reviewer expects to see and the complexity you're expected to know without looking up.
copy, late-binding closures — are that one fact in different costumes.4.1The Data Model and Object Semantics
Mutable vs immutable, and what's the difference between is and ==?
== asks about value and is customisable via __eq__; is asks whether two names refer to the identical object and compares identity (in CPython, addresses). Immutable types — int, float, str, bytes, tuple, frozenset — can't be changed in place, so x += 1 creates a new object and rebinds. Mutable types — list, dict, set, and most of your own classes — are modified in place, and every reference sees the change.
Use is only for singletons: is None, is True/is False when you specifically mean the singleton and not a truthy value, and sentinel objects you created with object(). Never use it for numbers or strings — small ints (−5 to 256) and some string literals are cached, so a is b is accidentally True for 256 and False for 257, and relying on that is relying on an implementation detail. Modern Python emits a SyntaxWarning for is against a literal, which is worth knowing as evidence the language considers it a mistake.
Is Python pass-by-value or pass-by-reference?
Neither, exactly — it's "pass by assignment" or "pass by object reference." The parameter name inside the function is bound to the same object the caller passed. So mutating that object is visible to the caller, but rebinding the parameter name is not:
def f(lst, n):
lst.append(1) # mutates the caller's list -> visible
lst = [99] # rebinds the local name only -> invisible
n += 1 # int is immutable; rebinds local -> invisibleThis is why "immutable arguments behave like pass-by-value and mutable ones like pass-by-reference" is a decent working heuristic but the wrong mental model — the difference isn't in the passing, it's in whether the object supports mutation. The follow-up you should be ready for: to let a function report a change, return the new value rather than mutating an argument; a function that both mutates its argument and returns something is a review comment waiting to happen.
What's wrong with def f(items=[])?
Default arguments are evaluated once, at function-definition time, and stored on the function object (f.__defaults__). So one list is shared across every call that omits the argument, and it accumulates:
def add(item, items=[]):
items.append(item)
return items
add(1) # [1]
add(2) # [1, 2] <-- same list
def add(item, items=None): # the fix
if items is None:
items = []
items.append(item)
return itemsThe same trap covers {}, set(), and any mutable object — including datetime.now() as a default, which freezes at import time rather than being wrong-per-call. Worth adding that this behaviour is occasionally used deliberately for a per-function cache or to bind a loop variable early (lambda x, i=i: ...), so it's a design choice with real uses, not purely a wart — but as a default value it's almost always a bug.
Shallow copy vs deep copy.
b = a creates no copy at all — two names, one object. A shallow copy (a.copy(), list(a), a[:], copy.copy(a)) makes a new outer container whose elements are still the same objects, so mutating a nested list shows up in both. copy.deepcopy(a) recursively copies everything, tracking already-copied objects in a memo dict so shared references stay shared and cycles don't recurse forever.
The practical guidance: shallow is what you want the vast majority of the time, and it's dramatically cheaper. deepcopy is slow (it goes through the pickle-like __deepcopy__/__reduce__ protocol), can fail or do something surprising on objects holding file handles, locks, sockets or DB connections, and is often a sign that the data structure should have been immutable. If a type needs custom behaviour, implement __copy__ and __deepcopy__. And the mutable-default trap's cousin: [[0]*3]*3 gives you three references to the same row, so grid[0][0] = 1 changes all three — use a comprehension.
What is the __hash__/__eq__ contract, and why can't a list be a dict key?
The contract: if a == b then hash(a) == hash(b), and an object's hash must not change over its lifetime. Dicts and sets bucket by hash and then confirm with ==, so violating the first half loses keys and violating the second half strands them — the object sits in a bucket the lookup no longer probes, and it becomes unfindable and undeletable while still occupying space.
Lists are unhashable precisely because they're mutable and would break the second half. Tuples are hashable if their contents are. In your own classes: defining __eq__ sets __hash__ to None (making instances unhashable) unless you also define __hash__ — that's Python protecting the contract for you. The safe pattern is to hash the same immutable fields you compare, usually as hash((self.a, self.b)); with dataclasses, frozen=True gives you both correctly, and eq=True, frozen=False deliberately gives you an unhashable type.
What does "everything is an object" actually buy you?
Functions, classes, modules, and types are all first-class objects with attributes. That means you can pass a function as an argument, return a class from a function, store types in a dict for dispatch, attach attributes to a function object, and introspect anything at runtime with type(), dir(), inspect. Decorators, ORMs, plugin registries, and pytest's fixture magic all fall out of it.
The companion idea is duck typing: what matters is which methods an object supports, not what it inherits from. So the idiomatic way to accept "a file-like thing" is to call .read() on it, not to check isinstance(x, File). Modern Python gives you a way to write that contract down without giving up the flexibility — typing.Protocol is structural, so a class satisfies it by having the right methods, with no inheritance and no registration. That's the answer that shows you know both the dynamic idiom and how to make it checkable.
How does Python manage memory — reference counting or garbage collection?
Both. CPython's primary mechanism is reference counting: every object carries a count, it's freed the instant the count hits zero, which makes deallocation deterministic and prompt (this is why del and going out of scope release memory immediately, and why RAII-ish patterns via __del__ almost work). Reference counting cannot collect cycles — a.b = b; b.a = a leaves both at count 1 forever — so there's a second, generational cyclic collector that periodically walks container objects and reclaims unreachable cycles.
Details worth knowing: the cyclic GC only tracks container types, so a cycle of pure-data objects still needs it but a large tuple of ints costs nothing to scan; you can disable it with gc.disable(), which some latency-sensitive services do (accepting cycle leaks) because a gen-2 collection walking millions of objects is a visible pause; and freed memory goes back to CPython's internal pool arenas, not necessarily to the OS, which is why RSS often doesn't drop after you delete a large structure. Also, __del__ is not a reliable destructor — it may run late, may run during interpreter shutdown when globals are already gone, and a __del__ on an object in a cycle historically made the cycle uncollectable. Use a context manager for cleanup.
What is __slots__ and when is it worth it?
By default every instance carries a __dict__ to hold arbitrary attributes, which costs memory (a per-instance dict, though key-sharing since 3.3 softened that a lot) and an extra hash lookup per attribute access. Declaring __slots__ = ('x', 'y') replaces it with fixed descriptor slots: typically 30–50% less memory per instance and modestly faster attribute access.
Costs: no new attributes at runtime (which is often a feature — it catches typos that would otherwise silently create a field), no __dict__ or __weakref__ unless you add them to the slots, and interaction with inheritance is fiddly — if any base lacks __slots__, instances get a dict anyway and you've gained nothing. The rule: reach for it when you have hundreds of thousands of small instances, and measure. For a handful of objects it's noise, and @dataclass(slots=True) is the ergonomic way to get it when you do want it.
4.2Functions, Scope, Closures, Generators
Explain scoping: LEGB, global, nonlocal.
Name lookup goes Local → Enclosing (any enclosing function) → Global (module) → Builtins. Assignment is what makes a name local: writing to a name anywhere in a function makes it local for the whole function, which is why reading it before that line raises UnboundLocalError rather than falling back to the global. global x says assignments bind the module-level name; nonlocal x says bind the nearest enclosing function's name.
Two things that round the answer out. Class bodies are a scope but not an enclosing scope for methods — a comprehension inside a class body can't see other class attributes, which is a genuinely confusing failure. And Python has no block scope: a name bound inside an if or a for survives past it, which is why for-loop variables leak (deliberately — it's how you use the last value) while comprehension variables do not, since Python 3 gave comprehensions their own scope.
What is a closure, and why does this loop print the same number three times?
A closure is a function that keeps references to names from its enclosing scope; you can see them in f.__closure__. The catch is that it closes over the variable, not the value at definition time — late binding. So:
fs = [lambda: i for i in range(3)]
[f() for f in fs] # [2, 2, 2] -- all see the final i
fs = [lambda i=i: i for i in range(3)] # bind now via default arg
fs = [functools.partial(lambda i: i, i) for i in range(3)] # or partial
[f() for f in fs] # [0, 1, 2]The same bug shows up wherever a callback is created in a loop — event handlers, retry wrappers, threads given target=lambda: work(i). The two fixes are the default-argument trick (evaluated at definition, which is the mutable-default behaviour used on purpose) and functools.partial. Naming late binding explicitly is what distinguishes a real answer from "you need to capture the variable."
*args, **kwargs, and the / and * in a signature.
*args collects extra positional arguments into a tuple, **kwargs extra keyword arguments into a dict. At a call site the same symbols mean unpacking: f(*lst, **d). In a signature, a bare * makes everything after it keyword-only, and a / (3.8+) makes everything before it positional-only.
Those two markers are an API-design tool, not trivia. Keyword-only parameters stop callers writing resize(img, 1, 0, True) and let you add parameters later without breaking positional callers. Positional-only lets you rename a parameter freely because no caller can depend on the name, and it's how the builtins are specified (which is why len(obj=x) is an error). The full order is def f(pos_only, /, normal, *args, kw_only, **kwargs). For anything with more than two or three arguments, making the tail keyword-only is a cheap, permanent readability win.
How does a decorator work, and why functools.wraps?
A decorator is a callable taking a function and returning a replacement; @d above def f is exactly f = d(f), run at definition time. The replacement is usually a closure that wraps the original:
import functools
def retry(times): # decorator factory -> takes args
def deco(fn):
@functools.wraps(fn) # copy __name__, __doc__, __wrapped__ ...
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return fn(*args, **kwargs)
except TransientError:
if attempt == times - 1:
raise
return wrapper
return decofunctools.wraps copies the metadata across, so the decorated function still reports its own __name__, docstring and signature. Without it, tracebacks say wrapper, help() is useless, inspect.signature reports (*args, **kwargs), and tools that key on function names (pytest, Flask routing, pickling) break. It also sets __wrapped__ so the original is recoverable. Round it out by mentioning that a decorator on a method must handle self as just another positional argument, and that class-based decorators or functools.lru_cache/cached_property are the same mechanism with state.
What can a lambda not do, and when should you use one?
A lambda is a single expression: no statements, so no assignment (barring the walrus), no return, no raise, no try, no annotations, and no docstring. It's also anonymous, so it degrades tracebacks and can't be pickled — which matters the moment you hand one to multiprocessing.
Legitimate uses are short, throwaway, and passed immediately: a key= function, a small default factory, a quick predicate. Anything longer than a line, anything reused, and anything you'd want to unit-test should be a named def — a def costs one extra line and buys a name in the traceback. And PEP 8 explicitly says don't assign a lambda to a name (f = lambda x: x); if it needs a name, it needs a def. Also worth knowing: key=lambda x: x.attr is often better written key=operator.attrgetter('attr'), which is implemented in C and measurably faster in a big sort.
Iterator vs iterable vs generator.
An iterable implements __iter__ and can produce an iterator (a list, a dict, a file). An iterator implements __next__ and __iter__ (returning itself), is stateful, and is consumed exactly once — raising StopIteration when exhausted. A generator is an iterator the compiler writes for you from a function containing yield, or from a generator expression.
The consequence people trip on: an iterator is single-pass, so list(it) after you've already looped over it returns empty, and passing a generator to two consumers means the second gets nothing. zip, map, filter, enumerate, reversed, and every itertools function return iterators in Python 3, which is why len() doesn't work on them and why printing one shows an object repr rather than values. If you need to re-iterate, materialise with list(), or write a class whose __iter__ returns a fresh generator each call — that's the difference between an iterable you can loop twice and an iterator you can't.
Why use a generator instead of building a list?
Memory and latency. A generator computes one item at a time and holds only its frame, so it's O(1) space regardless of how many items pass through, and the first item is available immediately rather than after the whole list is built. That's what makes streaming a multi-gigabyte file line-by-line, or an infinite sequence, possible at all. The pattern that shows fluency is a pipeline of generators, where each stage is lazy and nothing is ever fully materialised:
lines = (l for l in open("events.log"))
parsed = (json.loads(l) for l in lines)
errors = (e for e in parsed if e["level"] == "ERROR")
first10 = itertools.islice(errors, 10) # only ~10 records ever parsedThe trade-offs to name: no len(), no indexing, single pass, and slightly higher per-item overhead than a list comprehension, so for a small collection you'll iterate several times a list is both simpler and faster. Also, exceptions inside a generator surface at the consumer, not at the point of creation, which can make tracebacks confusing.
What do yield from, .send(), and .close() do?
yield from iterable delegates: it yields everything the sub-iterable yields, and — the part that matters — it forwards send, throw and close to the sub-generator and evaluates to the sub-generator's return value. So it's not just a loop shortcut; it's what makes generator composition transparent.
gen.send(value) resumes the generator with value as the result of the yield expression, turning a generator into a coroutine you can push data into — the basis of the pre-async coroutine style, and the reason @contextlib.contextmanager can hand control back to you mid-function. gen.close() raises GeneratorExit at the suspension point so try/finally cleanup runs; a generator that catches GeneratorExit and yields again gets a RuntimeError. This machinery is exactly what await was built on top of — async def plus await is a distinct, cleaner surface over the same suspend-and-resume mechanism.
4.3Classes and Object-Oriented Python
Class attribute vs instance attribute — and where's the bug?
A class attribute lives on the class and is shared by all instances; an instance attribute lives in the instance's __dict__. Attribute lookup checks the instance first, then the class and its MRO, but assignment always writes to the instance — which is why self.count += 1 on a class-level count = 0 silently creates a per-instance shadow and leaves the class attribute at 0.
The real bug is a mutable class attribute:
class Cart:
items = [] # shared by every Cart ever created
def add(self, x):
self.items.append(x) # mutates the class attribute
class Cart:
def __init__(self):
self.items = [] # per-instance, correctIt's the mutable-default trap again, one scope up. Class attributes are the right tool for genuine constants, counters you intend to be shared (incremented as Cart.count += 1), and defaults for immutable values — anything mutable belongs in __init__.
What is the MRO, and what does super() actually do?
The Method Resolution Order is the linearised sequence Python searches for an attribute, computed by the C3 linearisation algorithm: a class comes before its bases, bases appear in the declared order, and the order is monotonic — if it can't satisfy all three constraints, the class definition itself raises TypeError. You can inspect it with Cls.__mro__.
super() is widely mis-taught as "call the parent class." It actually returns a proxy that continues the lookup at the next class in the MRO of the instance's type, which is not necessarily the caller's base. That distinction is the whole reason cooperative multiple inheritance works: in a diamond, super().__init__() from each class walks the MRO once and calls each initialiser exactly once, whereas a hard-coded Base.__init__(self) calls the shared base twice. For that to hold, every class in the hierarchy has to call super() and be tolerant about arguments (which is why cooperative mixins pass **kwargs through). Zero-argument super() works via a compiler-inserted __class__ cell, so it only works lexically inside a class body.
@staticmethod vs @classmethod vs a regular method.
A regular method receives the instance as self. A @classmethod receives the class as cls — and crucially the actual class, so it respects subclassing. A @staticmethod receives nothing special; it's a plain function that lives in the class namespace for organisation.
The important use of classmethod is the alternative constructor: Date.from_iso("2026-07-29") returning cls(...) works correctly for SubDate.from_iso(...) too, which a hard-coded Date(...) would break. It's also how you write registries and factory dispatch. staticmethod is for a helper that's conceptually part of the class but needs neither — and a reasonable reviewer will ask whether it should just be a module-level function, which it often should. Both are implemented as descriptors, which is the connective answer if the interviewer follows up on how the self-passing works at all.
How does @property work, and what is a descriptor?
A descriptor is any object defining __get__, __set__, or __delete__, stored as a class attribute. When you access obj.x and the class attribute x is a descriptor, Python calls its __get__ instead of returning it. Data descriptors (with __set__) take priority over the instance __dict__; non-data descriptors lose to it. This one mechanism implements property, classmethod, staticmethod, bound methods, and __slots__.
@property is a data descriptor whose __get__ calls your getter, letting you turn an attribute into computed logic without changing any caller — which is exactly why Python code doesn't need Java-style getters up front: you can add validation later without breaking the API. Use it for computed or validated values; don't use it to hide something expensive or side-effecting, because callers reasonably assume attribute access is cheap. functools.cached_property is the non-data-descriptor variant that computes once and then writes into the instance dict, so subsequent accesses skip the descriptor entirely. Write a custom descriptor when the same attribute logic repeats across many fields — that's what ORM and validation-library fields are.
__str__ vs __repr__, and which dunders should a serious class define?
__repr__ is for developers: unambiguous, ideally something you could paste back into a REPL (Point(x=1, y=2)). It's what the REPL, the debugger, and a container's display use — so print([obj]) shows the repr, not the str. __str__ is for end users and falls back to __repr__ if undefined. Always define __repr__; define __str__ only when the friendly form differs.
The rest of the vocabulary, in rough order of value: __eq__ (with __hash__), __lt__ plus functools.total_ordering for sortability, __len__ and __iter__ and __contains__ for container-ish types, __getitem__/__setitem__ for subscripting, __enter__/__exit__ for resources, __call__ to make instances callable, and __bool__ (note that without it, a class with __len__ is falsy when empty — a real source of surprise). The framing to offer: dunders are how you opt into the language's protocols, and implementing the right ones is what makes a class feel native rather than bolted on.
dataclass, NamedTuple, TypedDict, plain dict — which and why?
@dataclass is the default for a mutable record: it generates __init__, __repr__, __eq__ from annotations, supports defaults and field(default_factory=list) (which is the sanctioned fix for the mutable-default trap), and with frozen=True gives you an immutable, hashable value type. slots=True and kw_only=True are both worth knowing.
NamedTuple is immutable, tuple-compatible (so it unpacks and indexes, useful when replacing an existing tuple return without breaking callers) and lighter in memory, but the tuple-ness leaks — it compares equal to a plain tuple, which occasionally matters. TypedDict annotates the shape of an actual dict, which is the right choice for JSON payloads and API boundaries where the runtime object must stay a dict. Plain dicts are fine for genuinely dynamic keys; the moment the keys are a fixed known set, a dataclass gives you attribute access, a real repr, and type checking for free. For validation and coercion at a system boundary — parsing untrusted input — pydantic is the usual step beyond dataclasses, since dataclasses do not validate types at runtime.
Abstract base classes vs Protocol.
An ABC (abc.ABC with @abstractmethod) is nominal: a class must inherit from it, and instantiating a subclass that hasn't implemented every abstract method raises TypeError at construction. That gives you an enforced contract and a place to put shared implementation, at the price of coupling — the implementer must import and subclass your base.
typing.Protocol is structural: any class with matching methods satisfies it, with no inheritance and no import. That's static duck typing, and it's the better fit for describing what your function needs (def render(x: SupportsRead) -> str) especially for third-party or built-in types you can't modify. Checking is by the type checker, not the runtime, unless you add @runtime_checkable — and even then isinstance only verifies method names, not signatures. The rule of thumb: ABC when you own the hierarchy and want shared code plus enforcement; Protocol when you're describing a requirement at a boundary you don't control.
What is a metaclass, and when would you genuinely use one?
A metaclass is the class of a class: type is the default, and class C(metaclass=M) means C = M('C', bases, namespace). Overriding __new__ or __init__ on the metaclass lets you inspect and rewrite a class at definition time — register it, validate it, inject methods, or transform annotated attributes into descriptors. That's how ORM models, serialisation libraries, and older plugin frameworks work.
The honest answer, though, is that you almost never need one now, and the interviewer usually wants to hear the alternatives: __init_subclass__ (3.6+) handles subclass registration and validation with a normal classmethod; __set_name__ lets a descriptor learn its own attribute name; class decorators cover "modify this class after it's built"; and abc.ABCMeta or Protocol cover interface enforcement. Reach for a metaclass only when you must control class creation itself — and expect the cost, which is that metaclass conflicts in multiple inheritance are genuinely painful and the machinery is invisible to readers of the subclass.
4.4Idioms, Data Structures, Performance
Comprehension, map/filter, or a loop?
Prefer a comprehension for building a collection from a transform and/or filter of another — it's the idiomatic form, it's faster than an append loop (the bytecode uses a specialised LIST_APPEND and skips the method lookup per item), and it reads as one expression. Use map/filter when the function already exists and no lambda is needed (map(int, parts) is clean and slightly faster than a comprehension calling int); map(lambda x: ...) is strictly worse than the comprehension. Use an explicit loop when there are side effects, multiple statements, or early exits — a comprehension executed for its side effects is a misuse.
Two details worth having ready. Comprehensions have their own scope in Python 3, so the loop variable doesn't leak — and as of 3.12 they're inlined rather than compiled as an implicit function, making them meaningfully faster. And there are dict, set, and generator comprehensions with the same syntax; swapping [...] for (...) turns an eager list into a lazy generator, which is the one-character fix for a memory problem when you only iterate once.
Why is += on a list different from += on a string or tuple?
+= tries __iadd__ first (in-place) and falls back to __add__ plus rebinding. Lists have __iadd__, so lst += [1] mutates in place — visible to every other reference, and equivalent to extend, which is why lst += "ab" surprisingly appends two characters. Strings and tuples are immutable, so s += "x" builds a new object and rebinds the name.
The famous consequence: t = ([1], ) then t[0] += [2] raises TypeError because the tuple assignment fails — after the list has already been extended. And on the performance side, string concatenation in a loop is O(n²) in principle because each step copies; CPython has an optimisation that makes the simple case look linear, but it's fragile (it breaks the moment another reference to the string exists). Use "".join(parts), which is O(n) and does one allocation, or an io.StringIO for incremental building.
Give me the complexity of the built-in containers.
| Operation | list | dict / set | deque |
|---|---|---|---|
| index / key lookup | O(1) | O(1) avg | O(n) |
| append / add | O(1) amortised | O(1) avg | O(1) both ends |
| insert / pop at front | O(n) | — | O(1) |
x in c | O(n) | O(1) avg | O(n) |
| delete by value / key | O(n) | O(1) avg | O(n) |
The two that decide real interview problems: x in list is a linear scan, so a membership test inside a loop is the accidental O(n²) that shows up in most "why is this slow" questions — convert to a set first. And list.pop(0) or insert(0, x) shifts every element, so a queue built on a list is O(n) per operation; collections.deque is O(1) at both ends. Worth naming the rest of collections too, since it's a common follow-up: defaultdict for grouping, Counter for frequency plus most_common, and heapq for a priority queue or a top-k in O(n log k).
Are dicts ordered? And how does a dict work internally?
Yes — insertion order is an implementation detail in 3.6 and a language guarantee from 3.7 onwards. It came from a memory optimisation: the dict is split into a compact array of entries in insertion order plus a sparse index array of positions into it. That layout cut dict memory by roughly 20–25% and preserved order as a side effect, which the language then adopted. collections.OrderedDict still exists and differs in two ways worth knowing: order-sensitive ==, and move_to_end/popitem(last=False), which is what you want for an LRU.
Mechanically, a lookup hashes the key, probes the index array (CPython uses open addressing with a perturbation-based probe sequence rather than chaining), and compares candidates with == — identity is checked first as a fast path, which is why interned string keys are so quick. It resizes when it's about two-thirds full, which rehashes every key, so building a huge dict has occasional latency spikes. Sets are the same machinery without values. Two implications: keys must be hashable and stable (the __hash__ contract from §4.1), and mutating a dict while iterating it raises RuntimeError — iterate over list(d) or build a new dict.
sort vs sorted, and how do you sort by several keys?
list.sort() sorts in place and returns None — a deliberate design choice to stop you writing x = y.sort(). sorted(iterable) accepts any iterable and returns a new list. Both take key= and reverse=, and both use Timsort: O(n log n) worst case, O(n) on already-sorted or reverse-sorted runs, and stable.
Stability is the answer to the multi-key question, twice over. For keys sorted the same direction, return a tuple: key=lambda p: (p.last, p.first). For keys sorted in opposite directions where the values aren't numeric (so you can't negate), sort repeatedly from the least significant key to the most, relying on stability to preserve the earlier order:
people.sort(key=operator.attrgetter("first")) # secondary, first
people.sort(key=operator.attrgetter("age"), reverse=True) # primary, last winskey is called once per element (unlike a comparator, which is called O(n log n) times), which is why key= is both faster and the only supported form — if you truly need a comparator, wrap it in functools.cmp_to_key.
Explain slicing, including the pitfalls.
s[start:stop:step] is half-open (stop excluded), tolerates out-of-range bounds without raising, accepts negative indices counting from the end, and with a negative step walks backwards — so s[::-1] reverses. Omitted values default to the ends, in step-aware fashion.
The pitfalls: lst[:] is a shallow copy, so nested objects are shared (§4.1); slicing a list copies the slice, which makes a "sliding window by slicing" loop quietly O(n·k); and for a large array you usually want a view instead — NumPy slices are views (mutating one changes the base array, which surprises people coming from lists), and memoryview gives you the same for bytes. Slice assignment is its own feature and worth knowing: lst[1:3] = [...] can change the list's length, and lst[::2] = ... requires a matching length. Finally, negative indices interact badly with computed bounds — lst[i-1:i+1] silently wraps to the end of the list when i is 0, which is a classic off-by-one that doesn't raise.
What are enumerate, zip, and the itertools you'd actually reach for?
enumerate(seq, start=0) replaces the range(len(seq)) anti-pattern and gives index and item together. zip(a, b) pairs elements and stops at the shortest input — which silently truncates when the inputs were supposed to be the same length, so use zip(a, b, strict=True) (3.10+) to make the mismatch an error, or itertools.zip_longest when short is expected. zip(*matrix) transposes, which is worth having in your pocket.
From itertools: chain to flatten one level of nested iterables lazily, islice to take from a generator without materialising, groupby (which groups consecutive runs, so you must sort by the same key first — the most common misuse), product/permutations/combinations for the search-space questions, accumulate for running totals, pairwise (3.10+) for adjacent pairs, and cycle/repeat/count for infinite sources. They're all C-implemented, lazy, and composable, which makes them the fastest way to express a stream transformation in pure Python.
Why is Python slow, and what do you do about it?
Three structural reasons. Every value is a heap-allocated object with a refcount and a type pointer, so a list of a million ints is a million objects plus a pointer array — poor locality and constant pointer chasing. Every operation is dynamically dispatched: a + b must look up types and find __add__ at runtime rather than emitting an add instruction. And the bytecode interpreter loop itself costs tens of nanoseconds per operation. On top of that, the GIL means pure-Python threads don't scale on CPU work.
The remedies, in the order you should try them: measure first (cProfile to find the hot function, then timeit or line_profiler on that function — the bottleneck is very often I/O or an accidental O(n²), not the interpreter); pick better algorithms and data structures; push the loop into C by vectorising with NumPy/Pandas or using a C-implemented builtin; then reach for a compiler — Numba's JIT for numeric loops, Cython or a Rust extension via PyO3 for a hot module, multiprocessing to use more cores. Also worth mentioning that CPython itself has improved substantially (3.11's specialising adaptive interpreter, and the 3.13+ JIT), so "Python is slow" deserves a version number attached.
4.5Exceptions and Context Managers
EAFP or LBYL — which is idiomatic and why?
EAFP ("easier to ask forgiveness than permission") is the Python default: attempt the operation and handle the exception. LBYL ("look before you leap") checks preconditions first. EAFP wins for two concrete reasons, not just taste. It avoids the TOCTOU race — between os.path.exists(p) and open(p) the file can vanish, so the check bought you nothing and you still need the handler. And it's faster on the success path, because try costs essentially nothing when no exception is raised (the setup is free in 3.11+ zero-cost exception handling), whereas a check runs on every call.
LBYL is still right when the check is cheap and the failure is expected and frequent — exceptions are not free when they do fire, so a validation loop where half the inputs are bad is better written with a check. And it's right when the operation has side effects you can't undo. The framing that lands well: use EAFP for the exceptional, LBYL for the routine.
Explain try/except/else/finally, including the odd interactions.
try holds the guarded code; except handles matching exceptions in order, first match wins (so put specific before general); else runs only if no exception was raised; finally always runs — on success, on exception, and on return/break/continue out of the block.
else is underused and worth explaining: it lets you keep the try to exactly the line that can fail, so a KeyError from your success path doesn't get swallowed by the handler meant for the lookup. The interactions to know: a return in finally replaces any return value and swallows any in-flight exception, which is a silent-bug generator (linters flag it); finally runs before the exception propagates, so cleanup happens in the right order; and the exception object is deleted at the end of the except block (Python 3 clears the name to break a reference cycle), so if you need it later assign it to another name.
Why is bare except: bad, and what is exception chaining?
except: catches everything, including KeyboardInterrupt and SystemExit — so it makes your program unkillable with Ctrl-C and turns sys.exit() into a no-op. Use except Exception: if you really need a catch-all (those two derive from BaseException, not Exception), and even then log with logging.exception and re-raise unless you have a specific reason to continue. A silent except Exception: pass is the single most expensive line in most debugging sessions.
Chaining preserves the causal trail. raise NewError("...") from err sets __cause__ and prints "The above exception was the direct cause of the following exception" — use it when translating a low-level error at a boundary. Raising inside an except block without from sets __context__ implicitly, so you still see both, labelled "During handling of the above exception, another exception occurred." raise ... from None deliberately suppresses the chain, which is right when the inner exception is an implementation detail you don't want leaking to your users. And a bare raise re-raises the current exception with its original traceback intact, which is what you want over raise err.
How should you design custom exceptions?
Inherit from Exception (not BaseException), define one base exception per library or subsystem, and derive the specific ones from it — that way callers can catch your whole surface with one except MyLibError or be precise when they care. Give the class the structured data as attributes rather than only formatting it into the message, so handlers can branch on err.retry_after instead of parsing a string.
Two more points that show you've maintained a library. Subclass the built-in that matches the semantics when one does — a config lookup that fails is arguably a KeyError subclass, and inheriting means existing generic handlers keep working. And keep exceptions cheap to construct and picklable, which means a custom __init__ should call super().__init__(...) with its args — an exception that can't be pickled breaks the moment it crosses a multiprocessing boundary, and it always crosses one eventually.
How do context managers work, and how do you write one?
with expr as x: calls expr.__enter__() (whose return value is bound to x) and guarantees __exit__(exc_type, exc, tb) runs on the way out, exception or not. If __exit__ returns truthy, the exception is suppressed — which is a sharp tool, and returning a bare True by accident silently swallows errors. It's Python's answer to RAII and the reason you should never write f = open(...) without a with.
The ergonomic way to write one is the generator form:
from contextlib import contextmanager
@contextmanager
def timed(label):
start = time.perf_counter()
try:
yield # everything before = __enter__
finally: # everything after = __exit__
log.info("%s took %.1fms", label, (time.perf_counter() - start) * 1e3)The try/finally around the yield is mandatory, not stylistic — without it an exception in the body skips your cleanup entirely, because it's raised at the yield. Also worth naming from contextlib: suppress, closing, ExitStack for a dynamic number of managers, and asynccontextmanager for the async with version.
Is assert appropriate in production code?
Not for anything you need to happen. assert statements are stripped entirely when Python runs with -O, so assert user.is_admin is a security hole that disappears under a flag someone else sets. And assert isinstance(x, int), "..." raises AssertionError, which is the wrong type for a caller to handle.
The correct division: assert for internal invariants that should be impossible to violate — a self-check that documents an assumption for the next reader and for the type checker (which narrows types on an assert). Explicit raise ValueError/TypeError for validating anything that came from outside your code: user input, a config file, a network payload, a public API argument. The other trap worth naming: assert (cond, "msg") with parentheses asserts a non-empty tuple, which is always true — a bug that passes every test.
4.6Concurrency: the GIL, Threads, Async
What is the GIL, precisely?
A mutex in CPython that allows only one thread to execute Python bytecode at a time. It exists because reference counts are not atomic — without it, every Py_INCREF would need its own lock or atomic, which historically cost more single-threaded performance than the parallelism was worth, and it also makes C extensions much easier to write correctly.
What it means in practice: threads do not give you CPU parallelism for Python code, no matter how many cores you have. What it does not mean: threads are useless. The GIL is released around blocking I/O (socket reads, file operations, time.sleep) and by C extensions that opt out — NumPy releases it for large array operations, as do many compression, hashing, and database drivers — so threads scale fine for I/O-bound and C-heavy numeric work. Two clarifications that mark a strong answer: the GIL protects the interpreter's internals, not your data structures, so you still need locks for your own invariants; and it's a CPython implementation detail, absent from Jython and IronPython, and now optional in CPython itself (see the free-threaded question below).
Threading, multiprocessing, or asyncio — how do you choose?
| Approach | Parallel CPU? | Best for | Main cost |
|---|---|---|---|
threading | No (GIL) | Blocking I/O, C libraries that release the GIL | Locks, races; ~8 MB stack and a few hundred µs per thread |
multiprocessing | Yes | CPU-bound pure-Python work | Process startup, pickling every argument and result, no shared memory by default |
asyncio | No | Very many concurrent I/O waits (thousands of sockets) | Needs async libraries end-to-end; one blocking call stalls everything |
The decision is almost entirely "what is this waiting on." CPU-bound pure Python needs processes (or a native extension). I/O-bound with a modest number of tasks: threads, via concurrent.futures.ThreadPoolExecutor, which is simpler than asyncio and works with existing blocking libraries. I/O-bound with thousands of concurrent connections: asyncio, because a coroutine costs a few kilobytes against a thread's megabytes. concurrent.futures is worth naming explicitly since it gives you one API (submit/map/as_completed) over both pools, so switching between threads and processes is a one-line change.
How does asyncio actually work, and what happens if you block in a coroutine?
A single-threaded event loop keeps a queue of ready tasks. async def defines a coroutine function; calling it returns a coroutine object that does nothing until it's awaited or scheduled. await on something not-yet-complete suspends the coroutine, yielding control back to the loop, which runs other tasks and resumes this one when its awaitable is ready. So concurrency here is cooperative: it's interleaving, not parallelism, and it only happens at await points.
Which is exactly why a blocking call is catastrophic rather than merely slow. time.sleep(5), requests.get(), a synchronous DB driver, or a heavy pure-Python computation inside a coroutine freezes the entire loop — every other connection, timer, and health check stalls, and the symptom looks like a mysterious latency spike across unrelated endpoints. The fixes: use the async equivalent (asyncio.sleep, httpx/aiohttp, an async driver), or push the blocking work off-loop with await asyncio.to_thread(fn, ...) for I/O and a ProcessPoolExecutor via loop.run_in_executor for CPU. Also worth mentioning: forgetting to await gives you a "coroutine was never awaited" warning and silently does nothing, which is the most common asyncio bug there is.
gather, TaskGroup, and cancellation.
await asyncio.gather(*coros) runs them concurrently and returns results in order; with return_exceptions=True failures come back as values instead of propagating. Its weakness is failure handling: by default the first exception propagates to the caller while the other tasks keep running unsupervised, which leaks work and hides errors. asyncio.TaskGroup (3.11+) is the structured-concurrency replacement — an async with block that guarantees every child task is finished when the block exits, and cancels the siblings if one fails, surfacing the failures as an ExceptionGroup you match with except*.
Cancellation is the part people underestimate: task.cancel() raises CancelledError inside the coroutine at its next suspension point, so cleanup must live in try/finally or an async with, and you must not swallow CancelledError (it derives from BaseException specifically so except Exception won't catch it). Also remember to keep a reference to any task from create_task — the loop only holds a weak reference, so a fire-and-forget task can be garbage collected mid-flight. And asyncio.wait_for/asyncio.timeout are how you bound anything talking to a network.
What are the gotchas with multiprocessing?
Everything crossing a process boundary must be picklable, and everything is copied. So lambdas, local functions, open file handles, sockets, DB connections and locks can't be passed; and sending a large DataFrame to each of eight workers means eight serialisations plus eight copies in memory, which routinely makes the parallel version slower than the serial one. The fix for big read-only data is multiprocessing.shared_memory or memory-mapped arrays, and for many small tasks it's chunking so per-task overhead amortises.
The start method matters and differs by platform: fork (Linux default historically, but not from 3.14, and never safe in a process that has threads — a forked child can inherit a locked mutex and deadlock) versus spawn (macOS and Windows default, safe, but re-imports your module in the child, which is why worker code must be guarded by if __name__ == "__main__": or you get infinite process spawning). Also: exceptions in a worker surface at the parent when you call .result(), so a pool that appears to succeed may be hiding failures until you consume the futures.
Which Python operations are atomic, and does free-threaded Python change that?
Yes, you need a lock. counter += 1 compiles to load, add, store — and the interpreter can switch threads between those bytecodes, so two threads can both read 5 and both store 6. The lost-update race is easy to demonstrate with two threads and a million increments. Use a threading.Lock, or better, don't share the counter: accumulate per-thread and combine at the end, or use a queue.Queue, which is internally locked and is the right primitive for handing work between threads.
Some operations are atomic because they complete within a single bytecode with no Python-level callback: list.append, dict[k] = v for simple keys, and reading or rebinding a single name. That's why "just use a Queue" and "just append to a list and merge later" are safe patterns. But relying on which operations are atomic is fragile — it depends on the interpreter, and it stops being true under free-threaded Python for your own compound invariants. The safe formulation: atomicity of a single operation never gives you atomicity of a sequence, and almost every real invariant spans more than one operation.
Does free-threaded Python change this?
PEP 703's free-threaded build (experimental in 3.13, officially supported and no longer experimental as of 3.14) removes the GIL, using biased reference counting, deferred reference counting for hot immortal objects, and per-object locks on containers instead. In that build, threads genuinely run Python bytecode in parallel, so the "use multiprocessing for CPU work" advice loosens considerably — threads with shared memory and no pickling become a real option for parallel CPU workloads.
The caveats to state, because they're what makes this a nuanced answer rather than a headline. It's a separate build (python3.14t), not the default, so you have to opt in. Single-threaded performance takes a hit versus the GIL build, though the gap has narrowed substantially from the initial ~10%. Every C extension must be rebuilt and audited for thread safety, and the ecosystem is still catching up — that's the real gating factor. And the important conceptual point: removing the GIL does not remove data races, it exposes them. Code that was accidentally correct because the GIL made list.append atomic and limited interleaving now needs actual locks, so it makes everything in the paragraph above more important, not less.
4.7Modules, Typing, Testing, Tooling
How does import work, and how do you fix a circular import?
import x finds the module (searching sys.path: script directory, then PYTHONPATH, then site-packages), executes it top to bottom exactly once, caches the module object in sys.modules, and binds the name. The "exactly once" is why module-level code is effectively a singleton initialiser, and why sys.modules is where you look when an import behaves impossibly.
A circular import fails when a imports b which imports a and then touches a name a hasn't defined yet — the partially-initialised module is already in sys.modules, so you get ImportError: cannot import name rather than infinite recursion. The fixes, in order of preference: extract the shared thing into a third module (the cycle is usually telling you a layer is missing); import the module rather than the name (import b then b.thing at call time, which defers the lookup); move the import inside the function; or, if it exists purely for annotations, guard it with if TYPE_CHECKING: and quote the annotation. And if __name__ == "__main__": matters here because it's the only reliable way to have module-level code that runs when executed but not when imported.
How do you manage environments and dependencies?
One isolated virtual environment per project, always — python -m venv at minimum — so projects can't fight over versions and so you can reproduce what you shipped. Declare dependencies in pyproject.toml with loose, intentional constraints for a library (so consumers can resolve), and pin exactly with a lock file for an application (so deploys are reproducible). That distinction between abstract dependencies and a concrete lock is the part interviewers are usually probing.
Tooling: uv has become the default answer in 2026 — it does resolution, locking, virtualenv management and Python-version installs, and is fast enough (Rust, aggressive caching) to run in CI on every build; Poetry and pip-tools are the established alternatives; plain pip install -r requirements.txt with a hash-pinned file still works fine. The practices worth naming regardless of tool: commit the lock file, use a hash-checked install in CI, keep dev/test dependencies in a separate group, and build the deployment artefact from the lock rather than resolving at deploy time — a resolver run inside a deploy is a nondeterministic step in your release.
Are type hints enforced at runtime? What are they for?
No. Annotations are stored in __annotations__ and otherwise ignored by the interpreter — def f(x: int) happily accepts a string. They're for static checkers (mypy, pyright), for editors, and for readers; PEP 563/649 have progressively made them lazily evaluated, so they're increasingly just metadata. Runtime enforcement is opt-in through a library that reads them: pydantic validates and coerces, typeguard checks, and FastAPI uses them for request parsing.
What's worth knowing beyond the basics: Optional[T] is T | None and means "may be None," which is the single highest-value annotation because it forces callers to handle the case; list[int] and dict[str, int] are the modern builtin generics (no typing.List needed since 3.9); Any disables checking and is a hole you should be able to justify; Protocol gives you structural typing (§4.3); TypeVar, or the 3.12 def f[T](x: T) -> T syntax, expresses "same type in and out." The pragmatic story for a legacy codebase is gradual typing: turn on mypy with lenient settings, annotate at module boundaries first, and tighten with disallow_untyped_defs per package as coverage grows.
How do you test Python code — fixtures, mocking, and where to patch?
pytest is the default: plain assert with rewritten introspection, fixtures instead of setUp/tearDown, and @pytest.mark.parametrize to turn a table of cases into separate tests with individual names. Fixtures are dependency injection by parameter name, they compose (a fixture can request another), and their scope= controls reuse — function by default, session for something expensive like a database container. A fixture with a yield is a context manager, so teardown is the code after the yield.
Mocking's one rule that everybody gets wrong: patch where the name is looked up, not where it's defined. If myapp.service does from requests import get, then patching requests.get does nothing — you must patch myapp.service.get, because that module bound its own reference at import time. Use autospec=True so the mock rejects calls with the wrong signature (a plain MagicMock accepts anything and lets your test pass while the real call would fail). And the more durable advice: prefer designing for injectable dependencies (pass the client in) over patching module globals, and prefer a fake or a real in-memory instance over a mock when the interaction has more than one step — heavily mocked tests assert your assumptions rather than the behaviour.
What modern syntax should I know: walrus, match, f-strings, structural pattern matching?
The walrus := assigns inside an expression, and its good uses are narrow: while (chunk := f.read(8192)):, if (m := re.match(p, s)):, and reusing an expensive value inside a comprehension's condition. It's a readability win exactly when it removes a duplicated computation, and a loss when it hides an assignment inside a complicated condition.
match (3.10+) is structural pattern matching, not a switch. It destructures while it matches — sequence patterns, mapping patterns, class patterns with attribute capture, guards with if, alternatives with | — which makes it genuinely good for parsing message shapes and walking an AST, and overkill for dispatching on an integer (a dict does that). The trap: a bare lowercase name in a pattern is a capture, not a comparison, so case ok: matches everything and binds it; you need a dotted name (case Status.OK:) to compare against a constant. f-strings are worth mentioning for f"{value=}" (prints the expression and its value, excellent for debugging) and format specs like f"{x:.2f}" and f"{n:_}"; since 3.12 they can contain nested quotes and backslashes, and they're faster than % or .format().
How do you debug and profile a Python program?
Debugging: breakpoint() drops into pdb from anywhere and respects PYTHONBREAKPOINT so you can redirect it to your IDE or disable it in CI; the commands worth knowing are n/s/c/l/w/p/pp and up/down to walk frames. For a hung process, faulthandler or py-spy dump gives you every thread's stack without stopping it, which is the tool for a production stall. Logging beats print: logging.exception inside an except block captures the traceback, and level-based configuration means you can leave the instrumentation in.
Profiling, in order: cProfile (deterministic, function-level, output read with snakeviz or pstats) to find which function; line_profiler to find which line inside it; py-spy for a sampling profile of an already-running process with no code change, which is what you use in production; timeit for microbenchmarks — and its pitfall is that it disables the GC and runs in a clean namespace, so results can differ from real behaviour. For memory, tracemalloc gives you allocation sites and memray gives you a much richer picture including native allocations. The discipline to state, though, is the same as anywhere: profile before optimising, and re-profile after, because the bottleneck moves.
in on a list inside a loop, a deepcopy nobody needed, an N+1 query, or a deepcopy-shaped serialisation crossing a process boundary — all of which are fixed by changing the code, not the runtime. Say "profile first" and mean it.