Python asyncio.gather and Timeouts
Run coroutines concurrently with asyncio.gather, enforce timeouts, and handle partial failures and cancellation correctly.
Python interviews in one place: data types and mutability, comprehensions, generators, decorators, OOP, the GIL, async and the standard library.
Official reference: Python 3 docs
Run coroutines concurrently with asyncio.gather, enforce timeouts, and handle partial failures and cancellation correctly.
Fix pip's error: externally-managed-environment (PEP 668) on Homebrew, Debian and Ubuntu: use a venv or pipx, not --break-system-packages.
Fix Python IndentationError: expected an indented block, unindent does not match any outer indentation level, and TabError from mixed tabs.
Fix Python KeyError: 'x': d[key] vs .get, in checks, setdefault and defaultdict, JSON payload validation, pandas columns and odd key reprs.
Fix ModuleNotFoundError: No module named 'x': installs into the wrong interpreter, inactive venvs, pip vs import names and python -m.
Fix TypeError: 'NoneType' object is not subscriptable: missing returns, list.sort(), failed re.match, dict.get and Optional type checks.
Fix UnboundLocalError: cannot access local variable (once 'referenced before assignment'): why assignment makes a name local, global, nonlocal.
Use asyncio.Queue and a fixed worker pool to bound pending work. Trace backpressure, shutdown and failure ownership.
Why time.sleep or a sync HTTP call inside a coroutine freezes every task, how to spot it with debug mode, and when to_thread is enough.
Who owns an asyncio task: why fire-and-forget loses errors, how TaskGroup cancels siblings, and why swallowing CancelledError hangs shutdown.
Why lambdas created in a loop all return the last value, what a closure cell really stores, and the three clean ways to capture a value.
How list, dict and set comprehensions scope their variables, why one fails in a class body, and when a plain loop is the better choice.
What the with statement guarantees, how __exit__ can swallow exceptions, the missing try/finally in @contextmanager, and ExitStack.
Assignment, shallow copy and deepcopy compared on a nested config dict, plus the [[0]*3]*3 grid bug and what deepcopy costs in practice.
Why a list default fails in a dataclass, what frozen=True does and doesn't protect, and how eq and hash decide if instances can be keys.
A decorator replaces your function, so it owes callers the same return value, exceptions and metadata. functools.wraps and async cases.
How raise ... from e, implicit chaining and from None shape a traceback, what __cause__ and __context__ hold, and when add_note is better.
A generator runs nothing until the first next(): why validation fires late, how lazy pipelines pull one item at a time, and why they empty.
What makes a Python object usable as a dict key or set member, why defining __eq__ removes __hash__, and how a mutated key gets lost.
is checks for the same object and == asks the object whether values match. Small-int caching, interning, None checks and sentinels.
An import runs the module's top-level code once and caches it. Config read too early, circular imports, __main__ guards and importtime.
Iterables versus iterators: why map, zip, files and csv readers empty after one pass, how zip and in eat items, and how to design for it.
Why def f(x, items=[]) keeps growing between calls, where Python stores that list, the None-sentinel fix, and why tags or [] is subtly wrong.
Joining a user-supplied name onto a base Path can escape it via .., absolute paths or symlinks. Contain it with resolve() and is_relative_to.
How Python's stable sort keeps ties in order, multi-key sorts with tuples or two passes, reverse=True versus [::-1], and None in keys.
Why a test passes alone and fails in the suite: leaked module state, environment and files. Fix it with fixtures, scopes and cleanups.
Measured on Python 3.14: threads speed up I/O waits but not pure-Python CPU work, processes do, and the GIL still allows lost updates.
What python -m venv creates on disk, why activation only edits PATH, why python -m pip beats pip, and how lock files make installs repeatable.
Both are ordered sequences that support indexing, slicing and iteration, and both can hold mixed types. The core difference is mutability:
append is amortized O(1).There's also a convention: a list is a variable-length collection of similar things ([user1, user2, ...]), while a tuple is a fixed-size record where position has meaning ((lat, lon)). That's why functions return multiple values as tuples.
One gotcha: tuple immutability is shallow. In t = ([1], 2) you can't reassign t[0], but t[0].append(3) works, and hash(t) raises TypeError because the list inside is unhashable.
Likely follow-up: How do you create a one-element tuple? · Why is ([1], 2) not usable as a dict key?
A mutable object can be changed in place after it's created; an immutable one can't, so every "change" produces a new object.
int, float, complex, bool, str, bytes, tuple, frozenset, Nonelist, dict, set, bytearray, and most instances of user-defined classesIt matters because Python variables are just names bound to objects. If two names refer to the same list, a mutation through one is visible through the other. With immutable types that can't happen: s += "!" on a string builds a new string and rebinds s, while lst += [1] extends the existing list in place, so every alias sees it.
Immutability is also what makes built-in objects safely hashable, which is why dict keys and set members are things like strings, numbers and tuples, never lists. And it's the root cause of the mutable-default-argument bug and of functions that surprise callers by mutating their arguments.
int, float, str, bytes, tuple, frozensetlist, dict, set, bytearray, most instancesLikely follow-up: What does += do differently for a list and a string? · Can a tuple ever change?
functools.wraps is used.midA decorator is a callable that takes a function and returns a replacement, usually a wrapper that adds behavior before or after the original call. The @timed syntax is just sugar for slow = timed(slow), applied once when the function is defined.
The wrapper accepts *args, **kwargs so it works with any signature, calls the original, and returns its result. Forgetting that return is a classic bug: the decorated function silently returns None.
functools.wraps copies the original's metadata (__name__, __qualname__, __doc__, __module__ and a few more), merges its __dict__, and sets __wrapped__ pointing to the original. Without it every decorated function reports its name as wrapper, which hurts logging, debugging, generated docs and any tool that introspects functions.
Common uses: logging, timing, caching (functools.lru_cache), retries, authorization checks and route registration in web frameworks. Stacked decorators apply bottom-up: the one closest to the def wraps first.
import functools, time
def timed(func):
@functools.wraps(func) # copy __name__, __doc__ etc., set __wrapped__
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed # same as: slow = timed(slow)
def slow(n):
return sum(range(n))@dec is sugar for f = dec(f) at definition time*args, **kwargs and returns the resultfunctools.wraps preserves name, docstring, sets __wrapped__Likely follow-up: How would you write a decorator that takes arguments? · How do you decorate a method, or a class?
yield work? Why would you use one instead of returning a list?midA generator function is any function that contains yield. Calling it doesn't run the body; it returns a generator object, which is an iterator. Each next() resumes execution until the next yield, hands that value out, and freezes the frame (local variables and position) until the next request. When the function returns, the generator raises StopIteration, which for loops handle for you.
Why use one:
__iter__ and __next__ needed.Trade-offs: a generator is single-pass (once exhausted it stays empty), with no len() and no indexing. Generators also support send(), throw() and close(), the machinery Python's coroutines were originally built on.
def countdown(n):
print("starting")
while n > 0:
yield n # pause here and hand n to the caller
n -= 1
gen = countdown(3) # nothing printed yet: the body hasn't started
print(next(gen)) # prints "starting", then 3
print(list(gen)) # [2, 1]
print(next(gen, "done")) # done (the generator is exhausted)yield produces a value and suspends the frameStopIteration when the function returnsLikely follow-up: What does yield from do? · How is a generator different from a generator expression?
[] as a default argument, and how do you fix it?easyDefault values are evaluated once, when the def statement executes, not on every call. The default list is stored on the function object (you can inspect it in add_item.__defaults__), so every call that omits bucket mutates that same list and state leaks from one call to the next.
The standard fix is a None sentinel: default to None and create a fresh object inside the function. If None is a meaningful value for callers, use a private sentinel such as _MISSING = object() and compare with is.
The same applies to any mutable default: dicts, sets, or class instances. Immutable defaults like 0, "" or () are safe because they can't be modified.
Tooling helps here: linters such as Pylint (and Ruff with its bugbear rules) warn about mutable defaults, and dataclasses refuses a bare [] field default with a ValueError, forcing you to write field(default_factory=list).
def add_item(item, bucket=[]): # [] is created once, when def runs
bucket.append(item)
return bucket
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] <- the same list again!
def add_item(item, bucket=None): # the fix: a None sentinel
if bucket is None:
bucket = [] # fresh list on every call
bucket.append(item)
return bucketNone, create inside the functionobject() sentinel if None is validfield(default_factory=list)Likely follow-up: Where does Python store default values? · Is it ever useful to rely on this behaviour?
is and == in Python?easy== checks equality: it calls __eq__, so two different objects with the same value compare equal. is checks identity: whether both operands are the very same object, equivalent to id(a) == id(b) while both are alive.
So [1, 2] == [1, 2] is True, but [1, 2] is [1, 2] is False because those are two separate lists.
Rules of thumb:
is / is not for singletons: None, and sentinel objects you create yourself. PEP 8 says comparisons to None should always use is or is not.== for everything else, including numbers and strings.Never rely on is for ints or strings. CPython caches small integers and interns some strings, so is sometimes happens to return True, but that's an implementation detail. Since Python 3.8, x is 5 even triggers a SyntaxWarning. Also, __eq__ can be overridden to return anything, while is can't be overridden at all.
== compares values via __eq__is compares object identityis for None and sentinelsis cannot be overridden; __eq__ canLikely follow-up: Why might a is b be True for 256 but not for 257? · Why is x == None discouraged?
*args and **kwargs mean in a function definition and in a function call?easyIn a definition, *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. The names are only convention; the * and ** are what matter.
The parameter order is: regular parameters, *args, keyword-only parameters, then **kwargs. Anything after *args (like sep above) can only be passed by keyword. A bare * forces keyword-only parameters without collecting extras, and / (Python 3.8+) marks the parameters before it as positional-only, e.g. def f(a, b, /, *, key).
At a call site the same operators work in reverse: *iterable spreads items into positional arguments and **mapping spreads into keyword arguments.
The classic real use is forwarding everything unchanged with func(*args, **kwargs), as decorators and wrapper functions do, or passing extra options up the chain with super().__init__(**kwargs).
def log(level, *args, sep=" ", **kwargs):
print(level, args, sep, kwargs)
log("INFO", 1, 2, sep="|", user="ana")
# INFO (1, 2) | {'user': 'ana'}
nums = [1, 2]
opts = {"sep": "-"}
log("DEBUG", *nums, **opts) # unpacking at the call site
# DEBUG (1, 2) - {}*args is a tuple of extra positional arguments**kwargs is a dict of extra keyword arguments*args or bare * are keyword-only/ marks positional-only parameters (3.8+)* and ** unpackLikely follow-up: What error do you get when passing the same argument twice? · Why would an API use positional-only parameters?
The GIL is a mutex in CPython that allows only one thread at a time to execute Python bytecode in an interpreter. It exists mainly because CPython manages memory with reference counting, and one global lock made refcount updates and the C API thread-safe cheaply, which also made C extensions easy to write.
Consequences:
multiprocessing or ProcessPoolExecutor, or move the hot loop into C extensions such as NumPy that release the GIL.time.sleep().counter += 1 is several bytecode steps and isn't atomic, so shared state still needs a threading.Lock.It's a CPython implementation detail, not a language rule. CPython 3.13 introduced an experimental free-threaded build without the GIL, and 3.14 made it officially supported, but the default build still has a GIL.
Likely follow-up: Why is counter += 1 not thread-safe even with the GIL? · What is free-threaded Python?
Assignment (b = a) copies nothing; it just adds a second name for the same object. A shallow copy creates a new outer container but fills it with references to the same inner objects. A deep copy recursively copies everything, so the result shares no mutable state with the original.
Ways to shallow-copy: copy.copy(x), lst[:], list(lst), lst.copy(), dict(d), d.copy(), {**d}. For a deep copy, copy.deepcopy(x).
In the example, appending to orig[0] shows up in the shallow copy because both outer lists point at the same inner list.
Details worth knowing: deepcopy keeps a memo dict, so shared references and cycles are reproduced rather than recursing forever. Classes can customize copying with __copy__ and __deepcopy__. Immutable objects like ints and strings aren't actually duplicated, since sharing them is safe. Deep copies are slow and can copy far more than you meant to, so a shallow copy plus rebuilding only the nested part you change is often better.
import copy
orig = [[1, 2], [3, 4]]
alias = orig # no copy at all: same object
shallow = copy.copy(orig) # also orig[:], list(orig), orig.copy()
deep = copy.deepcopy(orig)
orig[0].append(99)
print(alias is orig) # True
print(shallow) # [[1, 2, 99], [3, 4]]: inner lists are shared
print(deep) # [[1, 2], [3, 4]]: fully independentdeepcopy uses a memo to handle cycles__copy__ / __deepcopy__Likely follow-up: Does slicing a list create a deep or shallow copy? · When would deepcopy be a bad idea?
A list comprehension [x * x for x in data] runs immediately and builds the whole list in memory. A generator expression (x * x for x in data) looks almost identical but returns a lazy iterator that computes one item at a time as it's consumed.
Choose based on how the result is used:
len(), iterate it several times, or keep it around: use a list.sum(), any(), max(), "".join() or a for loop: use a generator. Memory stays constant and any() can stop early. As the sole argument you can drop the extra parentheses: sum(x * x for x in data).For a million items the list takes megabytes, while the generator object is a couple of hundred bytes. For small inputs a list comprehension can be slightly faster.
Gotchas: a generator is exhausted after one pass. In Python 3 both forms have their own scope, so the loop variable doesn't leak into the surrounding code. Set and dict comprehensions are eager, like list comprehensions.
sum, any, joinLikely follow-up: Why does iterating a generator expression a second time give nothing? · Is there a tuple comprehension?
Grouped by category:
int (arbitrary precision, never overflows), float (a C double, IEEE 754), complex, and bool, which is a subclass of int (True + True == 2).str (immutable Unicode text), bytes (immutable) and bytearray (mutable).list (mutable), tuple (immutable) and range (a lazy arithmetic sequence).set (mutable) and frozenset (immutable and hashable).dict, which preserves insertion order.None, the single instance of NoneType.Worth adding in an interview: Python is dynamically but strongly typed, so "1" + 1 raises TypeError instead of coercing. Everything is an object, including functions and classes. type(x) gives the exact type, while isinstance(x, T) also accepts subclasses and is usually what you want. And 0.1 + 0.2 == 0.3 is False because floats are binary fractions; use math.isclose or decimal.Decimal when it matters.
int (unbounded), float, complex, boolstr, bytes, bytearraylist, tuple, range, set, frozenset, dictisinstance over type(x) == checksLikely follow-up: Why is bool a subclass of int? · How would you represent money precisely?
Strictly, neither. Python uses what's usually called pass by object reference (or "call by sharing"): the parameter becomes a new local name bound to the same object the caller passed. Nothing is copied, but the name itself belongs to the function.
That produces two different outcomes:
items.append(4)) is visible to the caller, because both names point at one list.items = [0], or n += 1 on an int) only changes what the local name points to. The caller's variable is untouched.So it behaves like pass by value for immutable types (you can't change an int in place anyway) and like pass by reference for mutable ones, but it's one consistent rule. If a function must not affect the caller's data, copy it first or, better, return a new object. By convention, functions that mutate in place, like list.sort(), return None to make that obvious.
def modify(items, n):
items.append(4) # mutates the caller's list
items = [0] # rebinds the local name only
n += 1 # int is immutable: new object, local name only
nums, count = [1, 2, 3], 10
modify(nums, count)
print(nums, count) # [1, 2, 3, 4] 10NoneLikely follow-up: How would you stop a function from modifying a list you pass in?
@staticmethod, @classmethod and @property, with a use case for each.midAll three change how a function defined in a class is accessed.
self.@classmethod receives the class as cls, whether it's called on the class or on an instance. The main use is alternative constructors such as dict.fromkeys or datetime.fromtimestamp. Because it builds via cls, calling it on a subclass returns a subclass instance.@staticmethod receives nothing implicitly. It's a plain function that lives in the class namespace because it belongs there logically. If it doesn't really relate to the class, a module-level function is often cleaner.@property turns a method into a computed attribute read without parentheses (t.kelvin). Adding a @kelvin.setter lets you validate assignments. You can start with a plain attribute and add logic later without changing the public API, which is why Python code doesn't need Java-style getters and setters.Under the hood, all three are implemented as descriptors.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f): # alternative constructor: receives the class
return cls((f - 32) * 5 / 9)
@staticmethod
def is_valid(c): # plain function in the class namespace
return c >= -273.15
@property
def kelvin(self): # computed attribute, read without ()
return self.celsius + 273.15
print(Temperature.from_fahrenheit(212).kelvin) # 373.15classmethod gets cls; used for alternative constructorsclassmethod respects subclasses via clsstaticmethod gets no implicit first argumentproperty exposes a computed attribute without parenthesesLikely follow-up: How do you make a read-only property? · Can a classmethod be called on an instance?
with statement work under the hood?midA context manager sets something up on entry to a with block and reliably tears it down on exit, even if the block raises. The protocol is two methods:
__enter__(self) runs first; its return value is bound to the as target.__exit__(self, exc_type, exc, tb) always runs at the end. If the block raised, the exception details are passed in; otherwise all three are None. Returning a truthy value suppresses the exception, so normally you return None or False.It's essentially a reusable try/finally. Standard examples: open() closes the file, a threading.Lock is released, a database transaction commits or rolls back, decimal.localcontext() restores the previous precision.
For simple cases @contextlib.contextmanager lets you write one as a generator instead of a class. One with can manage several at once (with open(a) as f, open(b) as g:), and async code uses async with with __aenter__/__aexit__.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # bound to the "as" target
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
return False # don't swallow exceptions
with Timer() as t:
sum(range(10**6))
print(f"took {t.elapsed:.3f}s")__enter__ sets up; its return value binds to as__exit__ always runs, receiving exception details__exit__ suppresses the exceptiontry/finallycontextlib.contextmanager builds one from a generatorLikely follow-up: How would you write the same timer with contextlib? · What is contextlib.ExitStack for?
threading, multiprocessing and asyncio. How do you decide which one to use?midThey're three ways to do more than one thing at a time:
threading: OS threads in one process, sharing memory. With the GIL only one runs Python code at a time, so threads suit I/O-bound work (network calls, disk, subprocesses) where they mostly wait. Sharing data is easy, but you need locks and race conditions are easy to create.multiprocessing: separate processes, each with its own interpreter and GIL, giving true parallelism for CPU-bound work. The cost is higher startup time and memory, and data must be pickled to cross process boundaries.asyncio: one thread running an event loop that switches between coroutines at each await. It handles thousands of concurrent I/O-bound tasks cheaply, but libraries must be async-aware, and one blocking call stalls the whole loop.concurrent.futures puts ThreadPoolExecutor and ProcessPoolExecutor behind one API. My rule of thumb: CPU-bound, use processes; many concurrent network requests, use asyncio; some blocking I/O or non-async libraries, use a thread pool.
await pointsLikely follow-up: How do you call blocking code from asyncio? · Does free-threaded Python change this advice?
dict implemented, and why does it preserve insertion order?hardA dict is a hash table. To store a key, Python calls hash(key) and uses the hash to pick a slot; on lookup it hashes again, finds the slot, and confirms the match with ==. Collisions are resolved with open addressing: CPython probes other slots in a pseudo-random sequence derived from the hash. That gives average O(1) get, set and delete, with a rare O(n) worst case.
Since CPython 3.6 the layout is compact: a sparse array of small indices points into a dense array of (hash, key, value) entries stored in insertion order. That saved memory and made ordering a side effect, which Python 3.7 turned into a language guarantee. Iteration is fast because it just walks the dense array.
The table is resized when it gets about two-thirds full, so inserts stay amortized O(1). Consequences: keys must be hashable with consistent __hash__ and __eq__, mutating a key's hash-relevant state breaks lookups, and adding or removing keys while iterating raises RuntimeError.
== confirmsLikely follow-up: Why can two objects with equal hashes both be keys? · Is OrderedDict still useful?
__str__ and __repr__?easyBoth return a string representation, but for different audiences:
__repr__ is for developers: unambiguous, ideally valid Python that would recreate the object, like datetime.date(2024, 1, 15). It's what the REPL and debuggers show.__str__ is for end users: readable, like 2024-01-15. It's what print(), str() and f-strings use.If a class defines only __repr__, str() falls back to it, so __repr__ is the one to always implement. The reverse isn't true: with only __str__, the REPL still shows the default <__main__.Point object at 0x...>.
Two details interviewers like: containers use the repr of their elements, so print([d]) shows [datetime.date(2024, 1, 15)] rather than the friendly form. And in f-strings, !r forces the repr, so f"{name!r}" puts quotes around a string, which is handy in log and error messages.
__repr__: unambiguous, for developers, ideally recreates the object__str__: readable, for end usersstr() falls back to __repr__, not vice versarepr of their elements!r in f-strings forces reprLikely follow-up: What does print() call on a list of objects?
Choose by what you need to do with the data:
append are O(1), but x in lst, remove and insert(0, x) are O(n). The default for sequences you'll grow or reorder.The most common performance bug I look for is membership testing against a list inside a loop, which quietly turns O(n) code into O(n²). Converting the list to a set once fixes it. And remember that {} is an empty dict; an empty set is set().
in is O(n){} is a dict; use set() for empty setLikely follow-up: What is the complexity of list.pop(0), and what would you use instead?
global or nonlocal?midPython resolves a name by searching four scopes in order, LEGB: Local (the current function), Enclosing (outer functions, for nested functions), Global (the module's top level) and Built-in (len, print, Exception...).
The key rule is that scope is decided at compile time: if a function assigns to a name anywhere in its body, that name is local for the whole function. That's why print(x) followed by x = 1 inside a function raises UnboundLocalError, even when a global x exists.
To assign to an outer name you must declare it. global x rebinds the module-level name; nonlocal x rebinds the name in the nearest enclosing function. You need neither to read an outer variable or to mutate an outer object (items.append(1)), only to rebind the name.
Heavy use of global is a code smell; returning values or using a class is usually cleaner. Also note that a class body is not an enclosing scope for its methods.
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x # rebind outer's x instead of creating a local
x = "changed"
inner()
return x
print(outer()) # changed
print(x) # globalUnboundLocalErrorglobal rebinds module names; nonlocal rebinds enclosing onesLikely follow-up: Why is a class body not an enclosing scope for its methods?
A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished. make_multiplier(2) returns a function that still sees k; CPython keeps it alive in a cell object, visible via double.__closure__.
Closures capture variables, not values. Python uses late binding: the variable is looked up when the inner function is called. In the loop, all three lambdas close over the same i, and by the time they run the loop has finished with i == 2, so you get [2, 2, 2].
Fixes, each capturing the value at creation time:
lambda i=i: ifunctools.partial(func, i)i as a parameter, like make_multiplierClosures underpin decorators, function factories and callbacks. To reassign a captured variable, such as a counter, the inner function needs nonlocal.
def make_multiplier(k):
return lambda x: x * k # closes over k
double = make_multiplier(2)
print(double(5)) # 10
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]: i is looked up at call time
funcs = [lambda i=i: i for i in range(3)] # default arg binds the value now
print([f() for f in funcs]) # [0, 1, 2]partial, or a factorynonlocal needed to reassign a captured variableLikely follow-up: How would you build a counter with a closure? · How are closures related to decorators?
try/except/else/finally work in Python? What goes in each block?easytry: the code that might fail. Keep it as small as possible.except SomeError as e: runs only if a matching exception was raised. Catch specific exceptions; a broad except Exception hides bugs, and a bare except: even catches KeyboardInterrupt and SystemExit.else: runs only if the try block raised nothing. Code that depends on success goes here, so its own exceptions aren't accidentally caught by the handlers above.finally: always runs, whether the block succeeded, raised, or exited via return or break. Use it for cleanup, although a with statement is usually nicer.Handlers are checked top to bottom, so put subclasses before their base classes. Inside an except, a bare raise re-raises the current exception with its original traceback.
A gotcha: a return inside finally overrides the try block's return value and silently swallows any exception in flight. Python 3.14 now emits a SyntaxWarning for return, break or continue in a finally block (PEP 765).
except catches specific exceptions onlyelse runs only when no exception occurredfinally always runs, even after returnreturn from finally: it swallows exceptionsLikely follow-up: Why not just put the else code at the end of the try block? · What is the difference between raise and raise e?
A lambda is an anonymous function written as a single expression: lambda x, y: x + y. The expression's value is returned implicitly. It can take defaults, *args and **kwargs like a normal function, but it can't contain statements (no = assignments, no return, no try), and in tracebacks its name is just <lambda>. Otherwise it's an ordinary function object with the same closure and late-binding rules.
Good uses are short throwaway callbacks, especially key functions: sorted(users, key=lambda u: u.age), max(pairs, key=lambda p: p[1]), or a defaultdict(lambda: "n/a") factory.
When not to use one:
square = lambda x: x * x). Write a def, which gets a real name and can have a docstring.def.operator.itemgetter and operator.attrgetter are often clearer.def instead of naming a lambdadefLikely follow-up: What is operator.itemgetter and when would you prefer it?
map, filter and reduce work, and why are comprehensions often preferred?easymap(func, iterable) applies a function to every item, filter(pred, iterable) keeps the items where the predicate is truthy, and functools.reduce(func, iterable, initial) folds all items into one value. In Python 3, map and filter return lazy iterators, not lists, so wrap them in list() if you need one, and remember they're single-pass. reduce was moved out of the built-ins into functools.
Comprehensions are usually preferred because they read left to right and combine mapping and filtering in one place:
list(map(lambda n: n * n, nums)) becomes [n * n for n in nums]list(filter(lambda n: n > 0, nums)) becomes [n for n in nums if n > 0]map is still neat when you already have a named function, as in map(int, line.split()), and filter(None, items) drops falsy values. For reduce, a dedicated built-in is usually clearer: sum, math.prod, max, any, all or "".join.
map transforms, filter selects, reduce foldsmap/filter return lazy iterators in Python 3reduce lives in functoolssum, math.prod, any, all over reduceLikely follow-up: What does filter(None, items) return?
An iterable is anything you can loop over: it has an __iter__ method that returns an iterator (or supports the older __getitem__ sequence protocol). An iterator is the object that actually produces values: its __next__ returns the next item or raises StopIteration when done, and its __iter__ returns itself.
A for loop is essentially it = iter(obj) followed by repeated next(it) until StopIteration.
The distinction matters in practice. A list is iterable but not an iterator, so every for loop gets a fresh iterator and you can loop over it many times. Iterators such as file objects, generators, map and zip are single-pass and stay exhausted. Countdown above is its own iterator, which is why the second list(c) is empty.
To make a reusable iterable, have __iter__ return a new iterator each time. The easiest way is to write __iter__ as a generator function with yield.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # an iterator returns itself
def __next__(self):
if self.current <= 0:
raise StopIteration # tells the for-loop to stop
self.current -= 1
return self.current + 1
c = Countdown(3)
print(list(c), list(c)) # [3, 2, 1] []__iter__ returns an iterator__next__ plus __iter__ returning selfStopIteration signals the endfor calls iter() once, then next() repeatedlyLikely follow-up: How would you make Countdown re-iterable? · What does iter(callable, sentinel) do?
super() actually calls.hardAttribute lookup follows the class's Method Resolution Order (MRO), computed with the C3 linearization algorithm. It lists the class, then its bases, with two guarantees: every class appears before its own parents, and the left-to-right order of bases is preserved. Inspect it with C.__mro__ or C.mro().
super() doesn't mean "my parent". It means the next class after the current one in the MRO of the instance's type. In the diamond above, A's super().greet() calls B.greet, not Base.greet, because the object is a C. That's what makes cooperative multiple inheritance work: each class calls super() once and Base runs exactly once.
For that to work, every class in the chain must call super() and accept extra arguments (typically **kwargs) to pass along. If C3 can't find a consistent order, such as combining X(A, B) with Y(B, A), class creation fails with a TypeError. In practice, prefer small, focused mixins over deep diamonds.
class Base:
def greet(self): return ["Base"]
class A(Base):
def greet(self): return ["A"] + super().greet()
class B(Base):
def greet(self): return ["B"] + super().greet()
class C(A, B):
def greet(self): return ["C"] + super().greet()
print(C().greet()) # ['C', 'A', 'B', 'Base']
print([k.__name__ for k in C.__mro__]) # ['C', 'A', 'B', 'Base', 'object']super() means next in the instance type's MROsuper(), pass **kwargsTypeError at class creationLikely follow-up: How does the diamond problem play out in Python? · What is a mixin?
CPython uses two mechanisms:
gc module can trigger a collection (gc.collect()), tune thresholds, or disable it.Related points: small objects come from CPython's own allocator (pymalloc), so freed memory is often reused by the process rather than returned to the OS. weakref lets you refer to an object without keeping it alive, which helps with caches and cycles. All of this is CPython-specific: PyPy, for example, uses a tracing GC instead of reference counting.
gc module controls itweakref references without keeping objects aliveLikely follow-up: Why might a long-running Python process not shrink after freeing objects? · What problems can __del__ cause?
self and __init__?easyA class bundles data (attributes) and behavior (methods). Calling the class, Dog("Rex"), creates an instance: __new__ allocates it, then __init__ initializes it. So __init__ is an initializer rather than a true constructor, and it must return None.
self is the instance, passed automatically as the first argument when you call a method on it: rex.speak() is really Dog.speak(rex). The name is a convention, not a keyword.
Attributes set on self are instance attributes; names defined in the class body are class attributes, shared by every instance. A classic bug is a mutable class attribute like tricks = [] that all instances end up sharing.
With class Dog(Animal):, Dog inherits Animal's methods and can override them. If you override __init__, the parent's version isn't called automatically, so call super().__init__(...). Use isinstance and issubclass for type checks. Python has no truly private members: _name is a convention, and __name triggers name mangling to avoid clashes in subclasses.
__init__ initializes an instance __new__ createdself is the instance, passed implicitly__init__ needs explicit super().__init__()_x is convention; __x is name-mangledLikely follow-up: What happens if you define a list as a class attribute? · What is name mangling?
Both, in a sense. The reference implementation, CPython, first compiles source code to bytecode, a lower-level, platform-independent instruction set, and then interprets that bytecode in a virtual machine loop. The compile step is automatic and fast, which is why Python feels purely interpreted.
For imported modules, CPython caches the bytecode as .pyc files in __pycache__, so later imports skip compilation if the source hasn't changed. The script you run directly isn't cached. You can inspect bytecode with the dis module, e.g. dis.dis(func).
Other implementations take different approaches: PyPy uses a just-in-time compiler and is often much faster for long-running pure-Python code, and Cython compiles Python-like code into C extensions. Recent CPython versions also ship an experimental JIT that is off by default, so it's not something to rely on yet.
The key point: "Python" is a language specification, and CPython is its most common implementation.
.pyc in __pycache__Likely follow-up: What does the dis module show you?
if __name__ == "__main__": do, and why is it used?easyEvery module has a __name__ attribute. When a module is imported, __name__ is its module name ("greet"). When you run a file directly, with python greet.py or python -m greet, Python sets __name__ to "__main__" instead.
So the guard lets one file be both a reusable module and a runnable script: the code under it runs only when the file is executed directly, not as a side effect of being imported. Without it, importing the module (from a test, say) would kick off the script's work.
Good practice is to put the logic in a main() function and keep the guard to a single call, so tests can call main() directly and nothing leaks into module globals.
It's also essential with multiprocessing under the spawn start method (the default on Windows and macOS), because each child process re-imports the main module, and unguarded top-level code would run again in every child. For packages, a __main__.py file is what makes python -m package work.
# greet.py
def main():
print("running as a script")
if __name__ == "__main__": # True for "python greet.py" or "python -m greet"
main() # skipped on "import greet" (__name__ == "greet")__name__ is "__main__" when run directlymain() callmultiprocessing spawn start methodLikely follow-up: What is __main__.py used for in a package?
Dunder ("double underscore") methods are hooks into Python's data model: the interpreter calls them implicitly so your objects work with built-in syntax and functions. You rarely call them yourself.
__new__, __init__, __repr__, __str____eq__, __lt__, __hash____add__, __mul__, and reflected versions like __radd__ for 2 + obj__len__, __getitem__, __contains__ (for in), __iter____bool__ (falls back to __len__), __call____enter__, __exit__Just __len__ and __getitem__ already give you len(), indexing, iteration and in. When an operator method can't handle the other operand's type, it should return NotImplemented (not raise), so Python can try the reflected method on the other object. One subtlety: implicit calls look up dunders on the type, not the instance, so setting obj.__len__ = ... doesn't change len(obj).
__len__, __getitem__, __add__, __call__NotImplemented for unsupported operand types__bool__ falls back to __len__Likely follow-up: What is the difference between __getattr__ and __getattribute__? · When is __radd__ called?
__eq__ and __hash__ relate? What happens if you define __eq__ but not __hash__?hardBy default, objects compare and hash by identity: two instances are equal only if they're the same object. Once you define __eq__ to compare by value, you must keep the hash contract: if a == b, then hash(a) == hash(b). Dicts and sets depend on it, because they find the bucket by hash first and only then check ==.
Python enforces part of this. If a class defines __eq__ but not __hash__, its __hash__ is set to None, so instances become unhashable and adding one to a set raises TypeError. That's deliberate: the inherited identity-based hash would break the contract.
Best practice is to hash a tuple of exactly the fields __eq__ compares, and only make objects hashable if those fields never change. Mutating a key after it's in a dict or set leaves it in the wrong bucket, so lookups silently fail. Return NotImplemented for unknown types so Python can try the other operand. Dataclasses automate all this: @dataclass(frozen=True) generates a matching __eq__ and __hash__.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y)) # hash exactly the fields __eq__ uses
print(Point(1, 2) == Point(1, 2)) # True
print(len({Point(1, 2), Point(1, 2)})) # 1__eq__ sets __hash__ to None__eq__ comparesNotImplemented for unsupported typesLikely follow-up: Why is it fine for unequal objects to share a hash? · How does @dataclass decide whether to add __hash__?
async/await and the asyncio event loop work? What is the most common mistake people make with them?hardasync def defines a coroutine function. Calling it runs nothing; it returns a coroutine object that must be awaited or scheduled as a task. await suspends the current coroutine until the awaited operation finishes, handing control back to the event loop.
The event loop is a single-threaded scheduler. It runs a task until that task awaits something that isn't ready (a socket read, a sleep), then switches to another task that can make progress. This is cooperative multitasking: thousands of I/O-bound tasks overlap in one thread, like the two fetches above finishing in about one second total.
Key tools: asyncio.run() as the entry point, asyncio.create_task() to start work concurrently, asyncio.gather() or asyncio.TaskGroup (3.11+) to wait for several tasks, and asyncio.timeout() (3.11+) for deadlines.
The most common mistake is blocking the loop: time.sleep(), a synchronous HTTP client or heavy CPU work inside a coroutine freezes every task. Use async libraries, asyncio.to_thread() for blocking calls, or a process pool for CPU work. Forgetting await is the other classic bug.
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # suspends; the loop runs other tasks
return f"{name} done"
async def main():
results = await asyncio.gather(fetch("a", 1), fetch("b", 1))
print(results) # ['a done', 'b done'] after ~1s, not 2s
asyncio.run(main()) # creates the loop, runs main, closes itawait yields control back to the event loopgather/TaskGroup run tasks concurrentlyto_threadLikely follow-up: What is the difference between gather and TaskGroup? · How do you cancel a task, and what does it receive?
@dataclass do, and what options does it offer?mid@dataclass generates boilerplate from a class's type-annotated fields: __init__, a readable __repr__ and a field-by-field __eq__. Options add more:
order=True generates <, <=, > and >=, comparing fields as a tuple in declaration order.frozen=True makes instances immutable (assignment raises FrozenInstanceError) and, with the default eq=True, also hashable.slots=True and kw_only=True (3.10+) add __slots__ and keyword-only fields.field() customizes individual fields: default_factory for mutable defaults (a bare [] raises ValueError), and compare=False or repr=False to exclude a field. __post_init__ runs after the generated __init__ for validation or derived values.
Annotations are required to declare fields but aren't enforced at runtime. Compared with alternatives: namedtuple is immutable and tuple-like, while third-party libraries like Pydantic and attrs add runtime validation, conversion and more. For plain data holders in the standard library, dataclasses are the default choice.
from dataclasses import dataclass, field
@dataclass(order=True)
class Version:
major: int
minor: int = 0
tags: list[str] = field(default_factory=list, compare=False)
v = Version(1, 2)
print(v) # Version(major=1, minor=2, tags=[])
print(v == Version(1, 2)) # True: generated __eq__ compares fields
print(Version(1, 10) > v) # True: order=True compares (major, minor)__init__, __repr__, __eq__ from annotationsfrozen=True gives immutability and hashabilityorder=True adds comparison operatorsfield(default_factory=...) for mutable defaultsLikely follow-up: What is __post_init__ used for? · When would you pick Pydantic over a dataclass?
raise ... from ...)?midSubclass Exception, not BaseException, which is reserved for things like KeyboardInterrupt and SystemExit. A library typically defines one base class plus specific subclasses:
class PaymentError(Exception) as the base, so callers can catch everything from your library in one placeclass CardDeclined(PaymentError) with extra attributes such as self.code, set in __init__ after calling super().__init__(message)Use an Error suffix, as PEP 8 suggests, and expose attributes rather than making callers parse the message.
When translating a low-level error, use exception chaining: raise PaymentError("gateway timed out") from err. That sets __cause__, and the traceback shows both errors. If you raise inside an except without from, Python still records the original as __context__ ("During handling of the above exception, another exception occurred"). raise ... from None suppresses the original when it's just noise.
Newer tools: err.add_note(...) (3.11+) attaches extra context, and ExceptionGroup with except* (3.11+) handles several errors at once, such as failures from an asyncio.TaskGroup.
Exception, not BaseExceptionraise X from err sets __cause__from None hides the original exceptionLikely follow-up: What is the difference between __cause__ and __context__? · When would you use except*?
@retry(times=3)?hardIt needs one more level of nesting. @repeat(times=3) first calls repeat(times=3), which is a decorator factory: it returns the real decorator, which is then applied to the function. So there are three layers:
times)func)*args, **kwargs)Each layer is a closure over the one outside it, so wrapper sees both times and func. @wraps(func) still goes on the innermost function.
Real-world examples: a @retry(times=5, exceptions=(ConnectionError,)), @app.route("/users") in Flask, @lru_cache(maxsize=128) and @pytest.mark.parametrize(...).
A common gotcha is writing @repeat without parentheses: the function gets passed in as times and things fail confusingly later. A decorator meant to work both ways can check whether its first argument is callable, as lru_cache does. For stateful decorators, a class with __init__ and __call__ is an alternative to nested functions.
from functools import wraps
def repeat(times):
def decorator(func): # receives the function
@wraps(func)
def wrapper(*args, **kwargs): # receives the call's arguments
return [func(*args, **kwargs) for _ in range(times)]
return wrapper
return decorator
@repeat(times=3) # repeat(times=3) runs first, returns decorator
def greet(name):
return f"hi {name}"
print(greet("ana"), greet.__name__) # ['hi ana', 'hi ana', 'hi ana'] greet@dec(args) calls the factory first@wraps(func) goes on the innermost wrapperLikely follow-up: How would you implement @retry with exponential backoff? · How do you make a decorator usable with and without parentheses?
"".join() preferred over += in a loop?easyImmutable means no operation changes a string in place: methods that seem to modify one (replace, upper, strip) return a new string, and s[0] = "x" raises TypeError. The upside is that strings are hashable (usable as dict keys), safe to share between threads, and CPython can intern and reuse them.
The cost shows up when building a string piece by piece. s += piece in a loop can create a new string and copy everything so far on each iteration, so the total work can grow quadratically. CPython has an optimization that sometimes resizes in place, but PEP 8 explicitly says not to rely on it: it's fragile and absent in other implementations.
The idiom is to collect pieces in a list (or generator) and call "".join(pieces) once. join computes the total length, allocates once and copies each piece once, so it's linear. For formatting a few values, use f-strings; for many incremental writes, io.StringIO also works. Note that join requires strings: ", ".join(map(str, numbers)).
TypeError+= in a loop can be quadratic"".join() allocates once: linear time+= optimizationLikely follow-up: How would you reverse a string? · What does "".join([1, 2]) do?
Slicing is seq[start:stop:step]. start is inclusive and stop is exclusive; all three are optional, defaulting to the beginning, the end and a step of 1. Negative indices count from the end.
s[:3] is the first three items, s[-3:] the last three, s[1:-1] drops both endss[::2] takes every second item, s[::-1] is a reversed copylst[:] is a shallow copy of a listUseful properties: slicing never raises IndexError; it clips to the bounds, so "abc"[5:] is "" (plain indexing like "abc"[5] does raise). It works on any sequence (str, list, tuple, range, bytes) and returns the same type. List slices are copies, not views, unlike NumPy array slices.
Slice assignment can replace, insert or delete ranges and change the length: lst[1:3] = [7, 8, 9], lst[:0] = [0] to prepend, del lst[::2]. A slice(start, stop, step) object lets you name and reuse a slice, and it's what custom classes receive in __getitem__.
start inclusive, stop exclusive, optional step[::-1] reverses; [:] shallow-copiesLikely follow-up: Is lst[:] a deep or shallow copy? · How does a class support slicing in __getitem__?
A module is a single .py file; its top-level names become attributes of the module object. A package is a directory of modules. A regular package has an __init__.py, which runs on first import and can expose a curated API. Without one, the directory is a namespace package (PEP 420), which can even be split across several locations.
On import x, Python first checks the sys.modules cache, so a module's top-level code runs only once per process. Otherwise it searches sys.path: the script's directory, PYTHONPATH, the standard library and site-packages.
Other points interviewers look for:
from app.models import User) are preferred; relative ones (from .models import User) only work inside packages.__all__ controls what from module import * exports.python -m package.module so imports resolve correctly.__init__.py makes a regular packagesys.modules; code runs oncesys.pathLikely follow-up: How would you fix a circular import? · What is a namespace package?
A virtual environment is an isolated directory with its own interpreter link and site-packages, so each project gets its own dependency versions without clashing with other projects or the system Python. Create one with python -m venv .venv, activate it (source .venv/bin/activate on macOS and Linux), then install with python -m pip install .... Running pip via python -m guarantees you use the pip of the right interpreter.
Many OS-managed Pythons now refuse a global pip install with an "externally managed environment" error (PEP 668), which pushes you toward venvs anyway.
For dependency management:
pyproject.toml (the modern standard) or requirements.txt, with sensible version ranges.pip freeze, pip-tools, or the lock file of tools like Poetry or uv.site-packages per projectpython -m venv .venv, then activatepython -m pip to target the right interpreterpyproject.toml, pin with a lock fileLikely follow-up: What is the difference between requirements.txt and pyproject.toml? · Why pin transitive dependencies?
collections module do you use, and what problems do they solve?midcollections provides specialized containers that replace a lot of hand-written code:
Counter: a dict subclass for counting hashable items, with most_common(n) and arithmetic between counters. Missing keys return 0.defaultdict: calls a factory (list, int, set) to create the value for a missing key, perfect for grouping and counting.deque: a double-ended queue with O(1) appends and pops at both ends. Use it for queues and BFS instead of list.pop(0), which is O(n). maxlen makes it a bounded buffer.namedtuple: a tuple subclass with named fields, lightweight and immutable. typing.NamedTuple is the class-syntax version with type hints.OrderedDict: plain dicts keep insertion order now, but OrderedDict still offers move_to_end() and popitem(last=False) (handy for LRU caches), and its equality check is order-sensitive.ChainMap: layered lookups across several dicts, such as CLI args over environment variables over defaults.from collections import Counter, defaultdict, deque
print(Counter("mississippi").most_common(2)) # [('i', 4), ('s', 4)]
groups = defaultdict(list)
for word in ["apple", "avocado", "banana"]:
groups[word[0]].append(word) # no KeyError on a new key
print(dict(groups)) # {'a': ['apple', 'avocado'], 'b': ['banana']}
recent = deque([1, 2, 3], maxlen=3)
recent.append(4) # O(1) at both ends; the oldest item falls off
print(recent) # deque([2, 3, 4], maxlen=3)Counter for counting and most_commondefaultdict avoids missing-key boilerplatedeque has O(1) operations at both endsnamedtuple: immutable records with named fieldsOrderedDict still useful for move_to_endLikely follow-up: Why is list.pop(0) slow? · How would you build an LRU cache with OrderedDict?
functools.lru_cache and functools.partial do, and when would you use them?midlru_cache memoizes a function: results are stored keyed by the arguments, so repeated calls with the same arguments return instantly. maxsize bounds the cache (default 128) and evicts the least recently used entry; maxsize=None, or functools.cache (3.9+), makes it unbounded. cache_info() reports hits and misses, and cache_clear() resets it. The classic use is turning an exponential recursive solution, like naive Fibonacci or a DP problem, into a linear one.
Caveats: arguments must be hashable (passing a list raises TypeError), the function should be pure, and on methods the cache keeps references to self, which can keep instances alive. functools.cached_property is often better for per-instance values.
partial pre-fills some arguments and returns a new callable: parse_bin = partial(int, base=2), then parse_bin("1010") returns 10. It's handy for callbacks and map, and unlike a lambda it can be pickled (if the wrapped function can), which matters for multiprocessing.
Other useful members: wraps, reduce, total_ordering and singledispatch.
lru_cache memoizes by arguments; default maxsize 128cache (3.9+) is the unbounded versionpartial pre-binds arguments, and is picklableLikely follow-up: How would you cache a function that takes a list? · What does typed=True do in lru_cache?
key, the difference between sorted() and list.sort(), and sort stability.midsorted(iterable) returns a new list and accepts any iterable; list.sort() sorts a list in place and returns None, so x = lst.sort() is a classic bug. Both take key and reverse.
key is a function called once per element to compute the value that gets compared: len, str.lower, itemgetter(1) or a lambda. That's faster and simpler than old-style comparison functions; if you really need one, functools.cmp_to_key converts it.
Python's sort is stable: elements with equal keys keep their original relative order. That enables two techniques:
key=lambda p: (-p[1], p[0])The algorithm is derived from Timsort, a merge/insertion hybrid: O(n log n) in the worst case and close to O(n) on partly ordered data. Comparing incompatible types, like None with int, raises TypeError in Python 3.
from operator import itemgetter
people = [("ana", 30), ("bo", 25), ("cy", 30), ("di", 25)]
print(sorted(people, key=itemgetter(1)))
# [('bo', 25), ('di', 25), ('ana', 30), ('cy', 30)] ties keep input order
print(sorted(people, key=lambda p: (-p[1], p[0]))) # age desc, name asc
# [('ana', 30), ('cy', 30), ('bo', 25), ('di', 25)]
people.sort(key=itemgetter(0), reverse=True) # in place, returns None
print(people[0]) # ('di', 25)sorted() returns new list; .sort() is in place, returns Nonekey is computed once per elementLikely follow-up: How would you sort by one field descending and a string field ascending? · What does cmp_to_key do?
Type hints annotate the expected types of variables, parameters and return values: def greet(name: str, times: int = 1) -> str. They are not enforced at runtime; greet(42) still runs. Their value comes from static checkers like mypy and Pyright, IDE completion and refactoring, and libraries that read them on purpose, such as dataclasses, Pydantic and FastAPI.
The syntax has improved a lot:
list[int] and dict[str, float] (3.9+) instead of typing.Listint | None unions (3.10+) instead of Optional[int]def first[T](items: list[T]) -> T, class Box[T]: and the type Alias = ... statementfrom __future__ import annotationsOther everyday tools: Callable, Literal, TypeVar, Protocol for structural typing, TypedDict for dict shapes, Any as an escape hatch, and typing.cast. I add hints gradually, starting with public function signatures.
list[int], X | Nonedef f[T](...)Likely follow-up: What is the difference between Any and object? · How would you type a decorator?
__slots__ do, and what are its trade-offs?hardNormally each instance stores its attributes in a per-instance __dict__, which is flexible but costs memory. Declaring __slots__ fixes the set of attribute names up front, so instances store them in a compact layout and get no __dict__ (and no __weakref__ unless you list it).
Benefits:
AttributeErrorTrade-offs and gotchas:
__dict__ may break__slots__ gets a __dict__ back, losing the savingsx = 0 raises ValueError)TypeErrorIt's an optimization for many small, fixed-shape objects, not a default. @dataclass(slots=True) (3.10+) generates it for you.
class Point:
__slots__ = ("x", "y") # fixed attributes, no per-instance __dict__
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
p.x = 10 # fine
p.z = 3 # AttributeError: 'Point' object has no attribute 'z' and no __dict__ ...__dict__ with fixed slotsAttributeError__slots__ to keep savings__weakref__ is listedLikely follow-up: How does __slots__ interact with inheritance? · How would you measure the memory saving?
contextlib, and what other helpers does the module provide?midcontextlib saves you from writing a full class with __enter__ and __exit__.
@contextmanager turns a generator into a context manager: code before yield is the setup, the yielded value is bound to as, and code after it is the teardown. If the with block raises, the exception is re-raised at the yield, so wrap it in try/finally or the cleanup is skipped. (The standard library has had contextlib.chdir since 3.11; cd above is just an illustration.)
Other helpers worth knowing:
suppress(*exceptions): ignore specific exceptions, cleaner than try/except: passExitStack: manage a dynamic number of context managers, like a list of open filesclosing(obj): calls obj.close() on exitnullcontext(): a do-nothing stand-in when a manager is optionalredirect_stdout: temporarily capture printed outputasynccontextmanager: the async with versionThe decorator form suits simple setup/teardown pairs; a class is better when the manager carries state.
from contextlib import contextmanager, suppress
import os
@contextmanager
def cd(path):
old = os.getcwd()
os.chdir(path) # setup: runs on entering the with-block
try:
yield # the with-block body runs here
finally:
os.chdir(old) # teardown: runs even if the block raised
with suppress(FileNotFoundError):
os.remove("maybe-missing.tmp")@contextmanager: setup, yield, teardownyield in try/finally for guaranteed cleanupsuppress ignores specific exceptionsExitStack handles a variable number of managersnullcontext, closing, redirect_stdout for special casesLikely follow-up: What happens if the generator yields twice? · How would you open a variable number of files safely?
yield from do, and how is it different from looping over a sub-generator and yielding each item?hardyield from iterable delegates to another iterable or generator: it yields every value the sub-iterator produces, as if you'd written for x in sub: yield x. The simplest uses are chaining and flattening, as in the recursive flatten above.
With generators it does more than the loop:
yield from expression. A generator's return x travels in StopIteration.value, and yield from unpacks it, so outer receives "done".send(), throw() and close() to the sub-generator, so the caller effectively talks to the inner generator directly. A hand-written loop silently drops those.Historically, this two-way delegation was the mechanism behind generator-based coroutines before async/await arrived in 3.5. Today it's mostly used for composing generators and recursive traversals. One caution: recursive yield from still grows the call stack, so very deep structures can hit the recursion limit.
def flatten(items):
for item in items:
if isinstance(item, list):
yield from flatten(item) # delegate to a sub-generator
else:
yield item
print(list(flatten([1, [2, [3, 4]], 5]))) # [1, 2, 3, 4, 5]
def inner():
yield 1
return "done" # becomes StopIteration.value
def outer():
result = yield from inner() # receives "done"
yield result
print(list(outer())) # [1, 'done']return valuesend, throw and closeasync coroutinesLikely follow-up: How would you get a generator's return value without yield from?
a is b return True for 256 but False for 257 in some cases?hardIt's a CPython implementation detail. At startup CPython pre-allocates the integers from -5 to 256 and reuses them, so anything that produces one of those values gets the same object. Outside that range each computation typically creates a new object, so is returns False even though the values are equal.
Results can still look inconsistent because of compile-time constants: equal literals in the same code object are usually stored once. So a = 257 and b = 257 in one script or function often give a is b as True, while entering the same lines one by one in the REPL gives False. Strings behave similarly: identifier-like strings and some constants are interned, others aren't, and sys.intern() interns explicitly.
None of this is guaranteed by the language; it varies between implementations and versions, so code must never depend on it. That's why Python 3.8+ emits a SyntaxWarning for x is 257. The rule stays simple: is for None and sentinels, == for values.
a = 256
b = 256
print(a is b) # True: CPython pre-allocates ints -5..256
x = int("257")
y = int("257")
print(x is y) # False: two separate int objects
print(x == y) # True: compare values with ==== for values, is for NoneLikely follow-up: What is string interning?
Unpacking assigns the items of any iterable to several names at once: x, y = point. The counts must match or you get a ValueError.
Common patterns:
a, b = b, afirst, *rest = items or head, *middle, tail = [1, 2, 3, 4, 5], where middle is always a list (possibly empty)(x, y), label = (1, 2), "A"for i, (name, score) in enumerate(pairs): and for key, value in d.items():_, ext = os.path.splitext(path)The same operators unpack inside literals and calls: [*a, *b] concatenates iterables, {**defaults, **overrides} merges dicts with later keys winning, and f(*args, **kwargs) spreads arguments. Since 3.9 you can also merge dicts with defaults | overrides.
Unpacking works on any iterable, including strings and generators, and makes functions that return tuples read naturally: quotient, remainder = divmod(17, 5).
ValueError*rest collects leftovers into a lista, b = b, a swaps without a temporary[*a, *b] and {**d1, **d2} in literalsLikely follow-up: What does a, *b = [1] give for b?
f-strings (Python 3.6+) embed expressions directly in a string literal: f"Hello {name}". They're evaluated at runtime, are usually the fastest formatting option, and are the most readable.
After a : you use the same format-spec mini-language as format():
f"{price:.2f}" for two decimals, f"{n:,}" for thousands separators, f"{ratio:.0%}" for a percentagef"{name:>10}" right-aligns in 10 characters; f"{n:08b}" is zero-padded binaryf"{d:%Y-%m-%d}" formats a date!r uses repr(), and f"{x=}" (3.8+) prints x= followed by the value, great for debuggingPython 3.12 (PEP 701) relaxed the grammar: you can reuse the same quote type inside the braces, use backslashes, and split expressions across lines. Python 3.14 adds t-strings (t"...", PEP 750), which produce a Template object instead of a str, so libraries can process the interpolated values safely, for example to escape SQL or HTML.
One caveat: with logging, pass arguments separately (log.info("user %s", user)) so formatting happens only if the message is emitted.
:, e.g. .2f, ,, >10!r for repr; {x=} for debugging%s arguments in logging callsLikely follow-up: How do you include a literal brace in an f-string?
PEP 8 is the official style guide. The parts everyone should know: 4-space indentation; snake_case for functions and variables, PascalCase for classes, UPPER_CASE for constants; imports at the top, grouped as standard library, third-party, then local; and a 79-character line limit that many teams relax. In practice, formatters and linters like Black and Ruff enforce it automatically.
"Pythonic" goes beyond formatting: it means using the language's idioms.
for i, item in enumerate(items) instead of range(len(items)), and zip() for parallel iterationif not items: instead of if len(items) == 0:is None instead of == Nonedict.get() with a default, any() and all()import this prints the Zen of Python, and "readability counts" is the principle behind all of it.
enumerate, zip, unpacking over index loopsis NoneLikely follow-up: When would you prefer LBYL over EAFP?
itertools functions do you find most useful, and why use them over hand-written loops?miditertools provides fast, memory-efficient building blocks that accept any iterable and return lazy iterators:
count(start), cycle(seq), repeat(x)islice(it, n) (slicing for iterators, like the first 10 items of a generator), takewhile, dropwhile, filterfalsechain(a, b), chain.from_iterable(nested) to flatten one level, and zip_longest to pad the shorter inputgroupby(data, key) groups only consecutive equal keys, so sort by the same key first; pairwise (3.10+) yields overlapping pairs; batched(it, n) (3.12+) yields fixed-size chunksproduct, permutations, combinations, combinations_with_replacementaccumulate for running totals, tee to split one iterator into severalThey're implemented in C and compose into pipelines without building intermediate lists. Typical interview uses: product instead of nested loops, combinations for pair problems, and groupby after a sort.
islice slices any iterator, including infinite oneschain concatenates; chain.from_iterable flattens one levelgroupby needs input sorted by the same keyproduct, combinations, permutations replace nested loopsLikely follow-up: Why does groupby on unsorted data return repeated keys?
:=, and when is it appropriate to use it?midThe walrus operator (Python 3.8, PEP 572) is an assignment expression: it assigns a value to a name and also evaluates to that value, so you can bind and test in one place.
Typical uses:
if (m := pattern.search(line)) is not None: print(m.group(1))while (chunk := f.read(8192)):[y for x in data if (y := transform(x)) > 0]if (n := len(items)) > 10:Rules: an unparenthesized walrus can't be a statement on its own, so x := 5 is a SyntaxError while (x := 5) works; it can bind only plain names, not attributes or subscripts; and inside a comprehension it binds the name in the enclosing scope, so the name is still available after the comprehension.
Use it where it removes repetition or an awkward pre-assignment. If it makes a condition harder to read, an ordinary assignment on the line above is better.
while read loops and regex matchesx := 5 is a SyntaxErrorLikely follow-up: What is the value of y after [y for x in data if (y := f(x))]?
match statement work? Is it just a switch statement?midmatch (Python 3.10+, PEP 634) is structural pattern matching, which is much more than a switch. It tries each case top to bottom, runs the first one that matches, and never falls through.
Patterns can destructure data while matching:
case 200 | 201:case ["go", direction]: checks the length and binds direction; *rest captures the remaindercase {"type": "click", "x": x}: matches dicts containing those keys (extra keys are allowed)case Point(x=0, y=y): checks isinstance plus attributesif, the _ wildcard, and as to bind a subpatternThe big gotcha: a bare name is a capture pattern, not a comparison. case RED: matches anything and assigns it to RED; Python refuses to compile it if other cases follow. To compare against a constant, use a dotted name such as Color.RED.
It shines for parsing commands, walking ASTs, and handling JSON-like messages or events by shape.
def handle(command):
match command.split():
case ["go", direction]: # sequence pattern, binds a name
return f"moving {direction}"
case ["drop", *items] if items: # star capture + guard
return f"dropping {', '.join(items)}"
case ["quit" | "exit"]: # or-pattern
return "bye"
case _: # wildcard: matches anything
return "unknown"
print(handle("go north")) # moving north
print(handle("drop key map")) # dropping key, mapifLikely follow-up: How would you match a dataclass instance by its fields?
abc.ABC), and when would you use them?midAn abstract base class defines an interface that subclasses must implement. You inherit from abc.ABC and mark required methods with @abstractmethod. Neither the ABC nor any subclass that hasn't overridden every abstract method can be instantiated: you get a TypeError saying it can't instantiate an abstract class without an implementation for the missing method. That moves the error from "when the missing method is called" to "when the object is created".
ABCs can also supply concrete methods built on the abstract ones (the template-method pattern), and @abstractmethod combines with @property or @classmethod.
The standard library's collections.abc is the most common use: subclass Mapping and implement __getitem__, __iter__ and __len__, and you get keys, items, get and in for free. isinstance(x, collections.abc.Iterable) is also the idiomatic capability check.
Versus typing.Protocol: an ABC is nominal (explicit inheritance) and enforced at runtime; a Protocol is structural and aimed at static checkers.
ABC, mark methods @abstractmethodTypeError on instantiationcollections.abc mixins give methods for freeProtocol is structuralLikely follow-up: What does ABC.register() do? · When would you choose a Protocol instead?
typing.Protocol and TypedDict, and when would you use each?hardBoth come from typing and matter mainly to static type checkers.
A Protocol describes a shape: any class with matching methods and attributes is compatible, no inheritance required. That's structural typing, or static duck typing. For example, a SupportsClose protocol with a close(self) -> None method accepts files, sockets or your own classes in def shutdown(r: SupportsClose). It's ideal for describing what a function needs from third-party objects you can't make inherit from your base class. @runtime_checkable enables isinstance() checks, but those only verify that the members exist, not their signatures.
A TypedDict describes a dict with a fixed set of string keys and a type per key, like a JSON payload with title: str and year: int. At runtime it's a plain dict with no validation. total=False, or Required/NotRequired (3.11+) per key, controls which keys are optional, and ReadOnly (3.13+) marks keys that shouldn't be modified.
Rule of thumb: TypedDict when the data really is a dict (parsed JSON), a dataclass when you control the type, and Protocol to type behavior.
Protocol: structural typing, no inheritance neededruntime_checkable checks presence, not signaturesTypedDict: typed keys, plain dict at runtimeNotRequired / total=False for optional keysLikely follow-up: How is a Protocol different from an ABC? · How would you validate a TypedDict payload at runtime?
pytest discovers files named test_*.py or *_test.py and functions whose names start with test. Tests use plain assert statements, which pytest rewrites to show the compared values on failure, so there's no need for self.assertEqual.
A fixture is a function decorated with @pytest.fixture that provides setup for tests. A test requests one simply by naming it as a parameter, and pytest injects the result. With yield, code before it is setup and code after it is teardown, which runs even when the test fails.
Key features:
"function" (the default) runs per test; "class", "module", "package" or "session" share expensive resources such as a database connectionconftest.py: fixtures defined there are available to every test in that directory and below, without importsautouse=True applies one automaticallytmp_path, monkeypatch, capsys, caplog@pytest.mark.parametrize runs one test with many inputsFixtures beat setUp methods because each test declares exactly what it needs.
import pytest
@pytest.fixture
def db():
conn = make_test_db() # setup
yield conn # the value injected into the test
conn.close() # teardown: runs even if the test fails
def test_insert(db): # requested by parameter name
db.insert("ana")
assert db.count() == 1
@pytest.mark.parametrize("n, expected", [(2, True), (3, False)])
def test_is_even(n, expected):
assert (n % 2 == 0) == expectedassert with rewritten failure messagesyield fixtures: setup before, teardown afterconftest.pyparametrize runs one test over many inputsLikely follow-up: How would you mock an external API call in a pytest test? · What is the difference between tmp_path and tmpdir?
__new__ and __init__? When do you need __new__?hardCalling Cls(...) runs two steps. First __new__(cls, ...), an implicit static method, creates and returns the new object, normally via super().__new__(cls). Then, if the returned object is an instance of cls, Python calls __init__(self, ...) on it to initialize it. __init__ must return None.
You almost always need only __init__. __new__ is for controlling creation itself:
int, str or tuple: the value is fixed at creation, so __init__ is too late. A class Upper(str) must transform the value in __new__.__new__ builds the class object itself.Subtleties: if __new__ returns something that isn't an instance of cls, __init__ is skipped. With a singleton implemented in __new__, __init__ still runs on every call and can reset state, a classic gotcha. For singletons, a module-level instance is usually the more Pythonic answer.
__new__ creates the object; __init__ initializes it__init__ runs only if __new__ returns a cls instancestr__new__ still re-run __init__Likely follow-up: How would you implement a singleton in Python?
@property work under the hood?hardA descriptor is an object stored as a class attribute that defines __get__, and optionally __set__ or __delete__. When you access that attribute through an instance, Python calls those methods instead of doing a plain lookup. It's the mechanism behind property, classmethod, staticmethod, __slots__ and even ordinary methods: functions are descriptors whose __get__ returns a bound method, which is how self gets passed.
There are two kinds:
__set__ or __delete__ and take priority over the instance's __dict__. That's why a property can't be shadowed by assigning to the instance.__get__ (like functions); an instance attribute with the same name wins over them.__set_name__ (3.6+) tells the descriptor which attribute name it was assigned to. The example is a reusable validated field: any class can declare qty = Positive() and every assignment is checked, where a property would have to be repeated per attribute. ORMs such as Django and SQLAlchemy use descriptors for model fields.
class Positive:
def __set_name__(self, owner, name):
self.name = "_" + name # called when the class is created
def __get__(self, obj, objtype=None):
return self if obj is None else getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name[1:]} must be positive")
setattr(obj, self.name, value)
class Order:
qty = Positive() # a descriptor lives on the class
def __init__(self, qty):
self.qty = qty # goes through Positive.__set____get__/__set__/__delete__property, methods, classmethod, staticmethod__dict____set_name__ learns the attribute nameLikely follow-up: How are bound methods created? · Why can't an instance attribute shadow a property?
Classes are objects too, and a metaclass is the class of a class: it controls how class objects are created. The default metaclass is type, so type(int) is type. When Python executes a class statement, it runs the body to collect a namespace and then calls the metaclass, roughly type(name, bases, namespace), to build the class. A custom metaclass subclasses type and overrides __new__ or __init__ to inspect or modify classes as they're created: registering them, validating required attributes, or adding methods.
Real examples: abc.ABCMeta behind ABC, enum.EnumType (formerly EnumMeta) behind Enum, and model base classes in ORMs like Django's.
For application code, lighter tools usually do the job:
__init_subclass__ (3.6+) runs whenever a class is subclassed, great for plugin registries and validation__set_name__ on descriptorsMetaclasses also complicate inheritance: combining bases with unrelated metaclasses raises a "metaclass conflict" TypeError.
type is the default metaclass__init_subclass__ or class decoratorsLikely follow-up: How would you build a plugin registry with __init_subclass__?
Free-threaded Python is a CPython build with the GIL removed (PEP 703), so threads can run Python code on multiple cores in parallel. It's a separate build, typically installed alongside the normal one with a t suffix (for example python3.14t), not the default interpreter. It was experimental in 3.13; in 3.14 it became officially supported (PEP 779) but is still optional.
What I'd say, with appropriate hedging:
multiprocessing.threading.Lock.Check at runtime with sys._is_gil_enabled(), and control it with PYTHON_GIL or -X gil. I'd benchmark per workload before adopting it.
Likely follow-up: How would you check whether the GIL is active? · Does free threading make asyncio obsolete?
No questions match that filter.
Prefer multiple choice? All 22 Python MCQs with answers →