This page is for anyone facing a Python round, from a first developer job to a senior backend or automation role. Most Python interviews start with data types, mutability and copying, then move to functions, decorators, generators and comprehensions. Mid and senior rounds add the GIL, threads versus processes versus asyncio, exceptions and context managers, classes and the MRO, memory and type hints, plus a short coding task and a story from real work. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
List: ordered, changeable, allows duplicates; the default for a sequence you add to.
Tuple: fixed after creation, hashable when its items are; good for records and dict keys.
Set: unique items with fast membership tests; good for de-duplication and seen checks.
Dict: key to value lookup, fast on average, and keeps insertion order.
"I use a list when I have an ordered sequence I'll append to or sort, like rows I'm collecting. A tuple is for a small fixed record, like a latitude and longitude pair. Because it can't change, it's hashable as long as its items are, so I can use it as a dict key. A set is what I reach for when I only care whether something is there. Checking x in a set is constant time on average, while x in a list scans every item, so swapping a list for a set inside a loop is one of the easiest speed-ups in Python. A dict maps keys to values, like user id to user record, with the same fast lookup. Since Python 3.7, dicts are guaranteed to keep insertion order, which people often forget."
Saying a tuple is just a faster list, or using a list for membership checks inside a loop without seeing the cost.
Mutable: list, dict, set, bytearray and most custom objects.
Immutable: int, float, str, bytes, tuple, frozenset.
Passing: the function gets a reference to the same object; changing it in place is visible, rebinding the name is not.
"Lists, dicts, sets and most objects I write myself are mutable. Numbers, strings, bytes, tuples and frozensets are immutable. When I call a function, Python doesn't copy the argument. The parameter becomes a new name for the same object. So if the function does items.append(4), the caller's list changes too, because there's only one list. But if the function writes items = [4], it just points its local name at a new list and the caller sees nothing. With an immutable object like a string, anything that looks like a change actually builds a new object, so the caller is never affected. One trap: a tuple is immutable, but if it holds a list, that inner list can still change. In my own code I avoid mutating arguments unless the function name makes it obvious."
Saying Python copies arguments, or that it is pass by reference like C++ references so reassignment affects the caller.
is and ==, and when should you use is?Equality: == calls __eq__ and asks whether two values are equal.
Identity: is asks whether both names point to the very same object.
Rule: use is for singletons like None; use == for values.
"The double equals checks whether two objects have equal values, by calling the __eq__ method. The is operator checks identity: are these two names pointing at exactly the same object in memory. Two separate lists with the same items are equal but not identical. The place I use is on purpose is with singletons, mainly x is None, because there is only ever one None object and a class can't override is to fool it. What I never do is compare numbers or strings with is. CPython caches some small integers and some strings, so a is b can be true for small values and false for bigger ones, which makes code that relies on it pass tests and then fail on real data. Newer Python versions even print a warning when you use is with a literal."
Using is to compare strings or numbers, or saying the two are interchangeable.
x == None considered worse than x is None?Assignment: copies nothing; both names point at one object.
Shallow copy: a new outer container whose items are the same inner objects.
Deep copy: recursively copies inner objects too, so nothing is shared.
"Plain assignment doesn't copy anything, it just adds a second name. A shallow copy, like list(a), a[:] or copy.copy, makes a new outer container, but the items inside are the same objects as before. That's fine for a list of numbers. It bites with nested data. If I have a list of lists and shallow copy it, then append to one of the inner lists, the original sees the change too, because both outer lists point at the same inner lists. A deep copy with copy.deepcopy walks the whole structure and copies every level, so the two are fully independent. It even handles objects that refer to themselves. The cost is time and memory, so I only deep copy when I really need an independent snapshot, for example before mutating a config dict that other code also reads."
import copy
grid = [[1, 2], [3, 4]]
shallow = copy.copy(grid)
deep = copy.deepcopy(grid)
grid[0].append(99)
print(shallow[0]) # [1, 2, 99] inner list is shared
print(deep[0]) # [1, 2] fully independent
Thinking new = old makes a copy, or believing a shallow copy protects nested lists and dicts.
[[0] * 3] * 3 behave strangely when you change one cell?Reference counting: each object counts the references to it and is freed the moment the count hits zero.
Cycles: objects that refer to each other never reach zero, so a cyclic garbage collector finds and frees them.
del: removes a name, not an object.
Leaks in practice: usually objects still reachable, such as a growing cache or global list.
"In CPython, every object keeps a count of how many references point to it. Assigning it to a name, putting it in a list or passing it to a function adds one, and when a name goes out of scope or is reassigned, the count drops. When it reaches zero, the object is freed immediately, which is why files and sockets usually close promptly. Reference counting can't handle cycles, like two objects that point to each other or an object that refers to itself. So there's also a cyclic garbage collector that runs from time to time, finds groups of objects only reachable from each other, and frees them. The gc module lets me inspect or trigger it. Also, del doesn't delete an object, it removes one name. In practice, most Python memory leaks aren't collector bugs at all; something reachable, like a module-level cache, just keeps growing."
Saying del x frees the object, or that Python has no garbage collector because it uses reference counting.
def add_tag(tag, tags=[]), and how do you fix it?Cause: the default list is created once at definition time and reused on every call.
Symptom: tags from earlier calls leak into later ones.
Fix: default to None and create a fresh list inside the function.
"The default value is evaluated once, when Python runs the def line, not each time the function is called. So there's a single list shared by every call that doesn't pass tags. The first call returns a list with one tag, the second call returns two tags, and so on, and it looks like data is leaking between unrelated callers. The fix is to use None as the default and create the list inside the body when it's None. The same trap applies to dicts, sets and any other mutable default, and also to things like a timestamp from datetime.now, which would be frozen at import time. Linters flag this pattern, and I keep that rule switched on because it's easy to miss in review."
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag("a")) # ['a']
print(add_tag("b")) # ['b'], not ['a', 'b']
Not spotting the bug, or fixing it with tags = tags or [], which also replaces an empty list the caller passed in on purpose.
*args and **kwargs do, and what are keyword-only and positional-only parameters?Collecting: *args gathers extra positional arguments into a tuple; **kwargs gathers extra keyword arguments into a dict.
Unpacking: at the call site, * spreads a sequence and ** spreads a dict into arguments.
Keyword-only and positional-only: parameters after * must be named; parameters before / can't be.
"In a definition, *args collects any extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. The names are just convention; the stars do the work. I use them most in wrappers and decorators, where I want to accept anything and pass it through to another function unchanged. At a call site the stars work the other way: f(*values) spreads a list into separate arguments and f(**options) spreads a dict. Anything placed after a bare star, or after *args, becomes keyword-only, so the caller has to write timeout=5 instead of passing a mystery number. And a slash marks the parameters before it as positional-only, which lets a library rename them later without breaking anyone. I use keyword-only a lot for boolean flags."
def connect(host, /, port=5432, *, timeout=5):
return host, port, timeout
connect("db", 5433, timeout=2)
# connect(host="db") -> TypeError: host is positional-only
# connect("db", 5433, 2) -> TypeError: timeout is keyword-only
Saying args and kwargs are special keywords, or not knowing which one is a tuple and which is a dict.
*args, **kwargs harder to use and to type-check?functools.wraps add?Shape: a function that takes a function and returns a wrapper.
Wrapper: accepts *args, **kwargs, calls the original, returns its result.
wraps: copies the name, docstring and other metadata onto the wrapper.
"A decorator is just a function that takes a function and returns a new one. The at-sign syntax is shorthand for reassigning the name to that result. My timer defines an inner wrapper that takes star args and star star kwargs so it works for any signature. It reads a start time with time.perf_counter, which is meant for measuring durations, calls the original, and logs the elapsed time in a finally block so I still get a timing if the function raises. Then it returns the original result unchanged. Without functools.wraps, the decorated function would report its name as wrapper and lose its docstring, which confuses logs, debuggers and documentation tools. wraps copies that metadata across and also stores the original function on a __wrapped__ attribute."
import functools, logging, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
logging.info("%s took %.3fs", func.__name__, elapsed)
return wrapper
@timed
def load_report(day):
...
Forgetting to return the wrapped function's result, or a wrapper that only accepts a fixed number of arguments.
async def functions?@retry(times=3, delay=1)?Three layers: the factory takes the options and returns the real decorator, which returns the wrapper.
Retry loop: catch only the exceptions you expect, wait, try again, re-raise after the last try.
Care: keep functools.wraps, and never swallow the final error.
"With arguments there's one more layer. Writing retry(times=3) runs first and has to return the actual decorator, which then receives the function and returns the wrapper. So I end up with three nested functions: the factory holding the options, the decorator, and the wrapper holding the loop. Inside the wrapper I try the call, and if it raises one of the exception types I said were retryable, I sleep and try again. On the last attempt I let the exception propagate so the caller knows it really failed. I make the exception types a parameter, because retrying a ValueError from bad input is pointless, while retrying a timeout is sensible. Here the wait grows a little with each attempt. In production I'd make it grow faster, add some random jitter, and only retry operations that are safe to repeat."
import functools, time
def retry(times=3, delay=1.0, on=(ConnectionError, TimeoutError)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except on:
if attempt == times:
raise
time.sleep(delay * attempt)
return wrapper
return decorator
@retry(times=5, delay=0.5)
def fetch_rates():
...
Catching every exception and retrying, or a loop that ends silently and returns None instead of raising.
@retry and as @retry(times=5)?[f() for f in [lambda: i for i in range(3)]] return, and why?Answer: [2, 2, 2], not [0, 1, 2].
Why: a closure keeps the variable, not its value; i is looked up when the lambda runs.
Fix: bind the value now, with a default argument or functools.partial.
"It returns two, two, two. Each lambda is a closure over the variable i, not over the value i had when the lambda was created. Python looks the name up at the moment the lambda is called, and by then the loop has finished and i is two. All three lambdas share that same variable. The fix is to capture the value at creation time. The classic trick is a default argument, lambda i=i: i, because defaults are evaluated when the function is defined. functools.partial does the same thing more explicitly. I've seen this for real when building a list of button callbacks or scheduled tasks in a loop, where every callback ended up acting on the last item."
Guessing [0, 1, 2], or blaming lambdas when a normal nested def behaves exactly the same way.
nonlocal keyword do, and when do you need it?for loop that defines functions have the same problem?Use it: to build a new collection from another with a simple map and filter.
Forms: list, dict and set comprehensions, plus generator expressions for lazy results.
Avoid it: for side effects, deep nesting or logic that needs several statements.
"A comprehension is perfect when I'm building a new list, dict or set from an existing iterable with a simple transform and maybe one filter, like squares of the even numbers. It reads as one thought and is usually a bit faster than appending in a loop. For a dict I'd write something like {user.id: user for user in users}. If I only need to consume the values once, say inside sum or any, I use a generator expression with round brackets so no list is built at all. I switch back to a normal loop when there are side effects like printing or writing to a database, when I'd need more than two levels of nesting, or when the logic needs try and except. Also, in Python 3 the comprehension variable doesn't leak into the surrounding scope."
Cramming nested conditions into one unreadable line to show off, or using a list comprehension only for its side effects.
[x for x in data] and (x for x in data) in memory use?Runtime: hints are stored but not checked; Python runs the code the same way.
Value: a static checker such as mypy catches mistakes before running, and editors get better help.
Common forms: str | None or Optional[str], list[int], TypeVar for generics, Protocol for duck typing.
"Right, Python ignores hints when running the code. Passing a string where I wrote int won't raise anything by itself. The value comes from a static type checker, like mypy, run in CI. It catches things like calling a method on a value that might be None, or passing arguments in the wrong order, before the code ever runs. Hints also document the function better than a docstring and make editor autocomplete much more useful. For something that might be None, I write str | None on newer Python, or Optional[str] from typing on older versions, and the checker then forces me to handle the None case. For generic functions I use a TypeVar, and for duck typing a Protocol, which says any object with these methods is fine without inheritance. On an existing codebase I add hints gradually, starting with public functions."
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def find_user(user_id: int) -> dict[str, str] | None:
...
def shutdown(resources: list[Closeable]) -> None:
for r in resources:
r.close()
Thinking hints are checked at runtime, or treating them as decoration and never running a checker.
Any, and what does it cost you?Stream: read line by line so memory depends on the vocabulary, not the file size.
Normalise: lower-case and pull words out with a regex so punctuation doesn't split counts.
Count: collections.Counter with most_common.
"I'd open the file and loop over it line by line, because a file object is an iterator and only one line sits in memory at a time. For each line I lower-case it and pull out words with a small regex, so Hello, hello and hello with a comma all count as one word. Then I update a Counter, and at the end most_common(10) gives the answer. The memory used is roughly the number of distinct words, not the size of the file. Before writing it I'd ask a couple of questions: should numbers count as words, and what about apostrophes, like don't. If the vocabulary itself were too large for memory, I'd split the counting into chunks on disk or use a streaming approximation."
import re
from collections import Counter
WORD = re.compile(r"[a-z']+")
def top_words(path, n=10):
counts = Counter()
with open(path, encoding="utf-8") as f:
for line in f: # one line in memory at a time
counts.update(WORD.findall(line.lower()))
return counts.most_common(n)
Calling f.read().split() on a huge file, or hand-writing a counting dict when Counter exists.
What: a function with yield returns a generator that produces values one at a time on demand.
Why: constant memory for huge or endless streams; work starts only when someone asks.
Catch: single pass, no length, no indexing.
"A generator is what you get when a function uses yield. Calling it doesn't run the body. It hands back a generator object, and each time something asks for the next value, the function runs until the next yield, hands that value out and pauses with all its local state kept. So if I'm reading a ten gigabyte log file and filtering lines, a generator keeps one line in memory at a time, where a list would try to hold them all. It also lets me chain steps into a pipeline, like read, parse, filter, where each step pulls from the one before. The catch is that it's single use. Once it's exhausted, looping again gives nothing, and you can't take len or index into it, so if the caller needs those, a list is the better return type."
def error_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
yield line.rstrip()
for line in error_lines("app.log"): # one line in memory at a time
print(line)
Saying generators are faster at everything, or not knowing that the body doesn't run until the first next.
yield from do?Iterable: has __iter__, which returns a fresh iterator.
Iterator: has __next__, returns one item per call and raises StopIteration at the end; its __iter__ returns itself.
for loop: calls iter() once, then next() until StopIteration.
"An iterable is anything you can get an iterator from, meaning it has an __iter__ method. A list, a dict or a string is iterable. An iterator is the object that actually walks through the items: it has __next__, which returns the next item or raises StopIteration when it runs out, and its own __iter__ just returns itself. A for loop calls iter on the thing once, then keeps calling next until it sees StopIteration. The practical difference is that a list hands out a brand new iterator every time, so I can loop over it twice. A generator or an open file is its own iterator, so the second loop finds it already exhausted. If I write a custom collection, I make __iter__ a generator function, which is the simplest way to get a correct fresh iterator each time."
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self): # fresh iterator every time
n = self.start
while n > 0:
yield n
n -= 1
c = Countdown(3)
print(list(c), list(c)) # [3, 2, 1] [3, 2, 1]
Mixing up the two, or not knowing where StopIteration comes into a for loop.
next() directly on a list fail?Recursion: if an item is a list or tuple, flatten it; otherwise yield it.
yield from: hands every value from the inner generator straight through.
Edge cases: strings are iterable too, and very deep nesting can hit the recursion limit.
"I'd write it as a recursive generator. Loop over the items. If an item is a list or tuple, I yield from a recursive call on it, which passes every value from the inner generator straight through to my caller. Otherwise I yield the item itself. Because it's a generator, nothing is built up front, so it works on large inputs and the caller can stop early. I check for list and tuple explicitly rather than anything iterable, because a string is iterable too and each character is itself a string, so a generic check would keep recursing until Python raises a RecursionError. For extremely deep nesting, recursion can hit Python's recursion limit, and then I'd switch to an explicit stack of iterators instead of recursive calls."
def flatten(items):
for item in items:
if isinstance(item, (list, tuple)):
yield from flatten(item)
else:
yield item
print(list(flatten([1, [2, [3, 4]], (5,), "ab"])))
# [1, 2, 3, 4, 5, 'ab']
Checking for any iterable and recursing endlessly on strings, or building and concatenating lists at every level.
What: a lock in CPython that lets only one thread run Python bytecode at a time.
I/O work: the lock is released while waiting on network or disk, so threads still help there.
CPU work: pure Python code gets no parallel speed-up from threads; use processes or native code.
Not a safety net: your own shared data still needs locks.
"The GIL is the global interpreter lock in CPython, the standard interpreter. Only the thread holding it can execute Python bytecode, so at any instant only one thread is running Python code. That keeps the interpreter's internals, like reference counts, simple and safe. For I/O-bound work, threads are still useful, because a thread waiting on a socket or a file releases the GIL and others can run. For CPU-bound pure Python, like a big loop of calculations, threads won't use more than one core, so I use multiprocessing instead, or a library like NumPy that does the heavy work in C and can release the lock. One thing people get wrong: the GIL doesn't make my code thread-safe. Something like counter += 1 is several steps, and a thread switch can happen between them, so shared state still needs a lock. Newer CPython versions also offer an optional build without the GIL."
Saying threads never help in Python, or that the GIL means you never need locks.
counter += 1 not safe across threads even with the GIL?CPU-bound: processes, such as ProcessPoolExecutor, to use every core despite the GIL.
Blocking I/O, moderate scale: a thread pool around existing blocking libraries.
Many connections: asyncio, one thread juggling thousands of waits cheaply.
Costs: processes pay for start-up and pickling; asyncio needs async libraries end to end.
"Image resizing is CPU-bound, so threads would fight over the GIL. I'd use a ProcessPoolExecutor so each core runs its own interpreter, and pass file paths rather than image bytes, because anything sent between processes gets pickled and copied. For two hundred slow API calls, the time is all waiting, so a ThreadPoolExecutor with maybe twenty workers and a normal HTTP library is simple and works well. asyncio would also work, but only if I switch to an async HTTP client. For many long-lived websocket connections, asyncio is the natural fit. Each connection is a cheap coroutine instead of a thread with its own stack, and one event loop can handle thousands of mostly idle clients. The rule I use: what is the program waiting on, and how many things at once?"
One answer for all three jobs, or threads for the CPU-heavy image work.
await, and what goes wrong if it calls a blocking function?await: the coroutine pauses and hands control back to the event loop, which runs whatever else is ready.
One thread: tasks only switch at an await; nothing preempts them.
Blocking call: time.sleep or a blocking HTTP call freezes every task on the loop.
Fix: use async libraries, or push blocking work to a thread with asyncio.to_thread.
"asyncio runs everything on one thread with an event loop. When a coroutine awaits something that isn't ready, like a network read, it suspends and gives control back to the loop, which resumes any other task whose data has arrived. That's cooperative: a task only gives up control at an await. So if a coroutine calls time.sleep, a blocking database driver or a slow CPU loop, it never yields, and every other task on the loop freezes until it finishes. In a web service that looks like all requests stalling at once. The fix is to use async versions, like asyncio.sleep and an async HTTP client, and for blocking code I can't replace, run it in a thread with asyncio.to_thread or run_in_executor. To run things concurrently, I use gather or a task group rather than awaiting one call after another."
import asyncio
async def fetch(i):
await asyncio.sleep(1) # stands in for a network call
return i * 2
async def main():
results = await asyncio.gather(*(fetch(i) for i in range(5)))
print(results) # about one second in total, not five
asyncio.run(main())
Saying asyncio runs coroutines in parallel threads, or calling blocking libraries inside async def without noticing.
gather?Explain: in CPython, threads can't run Python bytecode in parallel, and switching adds overhead.
Measure: profile to see where the time actually goes before changing anything else.
Options: vectorised libraries, a process pool with sensible chunk sizes, or a better algorithm.
"First I'd make sure it doesn't feel like a telling-off, because it's a very common thing to try. I'd explain that in standard CPython only one thread runs Python code at a time because of the GIL, so for pure calculation the threads just take turns, and the switching and lock contention make it slower than one thread. Then I'd suggest we profile it together, because the fix depends on where the time goes. If it's number crunching in loops, moving that to NumPy or another library that does the work in native code is often the biggest win. If the work splits into independent pieces, a ProcessPoolExecutor uses every core, as long as we send reasonably large chunks so pickling data between processes doesn't eat the gain. And sometimes the real fix is a better data structure."
Telling them to just use more threads, or dismissing Python as too slow without measuring anything.
try: only the code that might raise the error you expect.
except: runs if a matching exception was raised; catch specific types.
else: runs only if try raised nothing; the follow-on work goes here.
finally: runs every time, even after a return or an unhandled error.
"The try block holds the code that might fail. If it raises, Python checks each except clause in order and runs the first one whose type matches, so I list specific exceptions first. If nothing was raised, the else block runs. I use else for the work that should happen only on success, which keeps the try block small, so I don't accidentally catch an error from code I never meant to protect. finally runs no matter what: success, a handled error, an unhandled error or even a return inside try. That's where cleanup goes, like releasing a lock, although a with statement usually does this better. One thing I avoid is a return inside finally, because it silently throws away any exception that was on its way out."
def read_port(path):
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
return 8080 # sensible default
else:
with f:
return int(f.read().strip())
finally:
print("checked", path)
Wrapping a whole function in one try with a bare except, or not knowing when else runs.
raise ... from ... do, and why is it useful?except: worse than except Exception:?with statement work, and how would you write your own context manager?Protocol: __enter__ runs at the start and its return value goes to as; __exit__ always runs at the end.
Errors: __exit__ receives the exception; returning True suppresses it.
Shortcut: contextlib.contextmanager turns a generator with one yield into a context manager.
"A with statement calls __enter__ on the object at the start, and whatever that returns is bound to the name after as. When the block ends, whether normally or because of an exception, Python calls __exit__ with the exception details, or with None if there was no error. If __exit__ returns True, the exception is suppressed, which I almost never want. That's how open closes the file even if my code crashes halfway. For my own, the quickest way is contextlib.contextmanager. I write a generator, do the setup, yield once, and put the teardown in a finally after the yield so it runs even on errors. I've used this for timing blocks, temporarily changing directories, and wrapping a database transaction so it commits on success and rolls back on failure."
from contextlib import contextmanager
@contextmanager
def transaction(conn):
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
with transaction(conn) as c:
c.execute("UPDATE accounts SET active = 0 WHERE id = 7")
Putting cleanup after the yield without try and finally, so it silently never runs when the block raises.
yield be inside try and finally in a generator-based context manager?contextlib.ExitStack for?except Exception: pass. What do you do?Ask first: which failure was the author trying to handle?
Risk: real bugs and bad data disappear with no trace.
Suggest: catch the specific exception, narrow the try block, log with context, and re-raise what you can't handle.
"I'd leave a comment asking what failure they were expecting, because there's usually a real reason, like an API that sometimes times out. Then I'd explain the risk. except Exception: pass hides every error, including typos, None bugs and bad data, so when something breaks later there's no log and no stack trace, just wrong results. I'd suggest catching only the specific exception they meant, like a timeout, and shrinking the try block to the one line that can raise it. If the failure is truly safe to skip, it should at least be logged with logger.exception so we keep the traceback, plus a short comment saying why skipping is fine. Anything we can't handle should propagate. I'd approve once it's narrowed; I wouldn't block the whole change over the approach."
Approving it because the code doesn't crash, or rejecting it with no explanation of the risk.
except: even worse than except Exception:?What: double-underscore methods Python calls for you, such as len(), +, == and printing.
Printing: __repr__ for developers and debugging; __str__ for end users.
Equality: define __eq__; defining it without __hash__ makes instances unhashable.
Shortcut: @dataclass generates these for plain data classes.
"Dunder methods, short for double underscore, are the hooks Python's syntax and built-ins call. len calls __len__, the plus sign calls __add__, a for loop calls __iter__. For printing I always write __repr__, ideally so it looks like the code that would rebuild the object, because that's what shows up in logs and the debugger. __str__ is optional, for a friendlier display. For value equality I write __eq__, and if the other object isn't my type I return NotImplemented so Python can try the other side. The catch is that defining __eq__ sets __hash__ to None, so the objects can't go in sets or be dict keys. If they're effectively immutable, I add a __hash__ built from the same fields. For simple data holders I just use a dataclass, and frozen=True gives me hashing too."
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __hash__(self):
return hash((self.amount, self.currency))
Writing __eq__ and then being surprised that the objects can't go in a set, or confusing __repr__ with __str__.
super() really call?MRO: a single linear order of classes, built with the C3 algorithm; see it with Class.__mro__.
Lookup: Python walks that order and uses the first class that defines the method.
super(): calls the next class in the MRO of the actual object, which may be a sibling, not the parent.
"Python flattens the inheritance graph into one list called the method resolution order, using an algorithm called C3. It keeps each class before its parents and keeps the left-to-right order of the bases you listed. When I call a method, Python walks that list and uses the first class that defines it. I can print it with ClassName.__mro__. The part people miss is super. It doesn't mean my parent. It means the next class after mine in the MRO of the object I'm actually dealing with. So in a diamond, where C inherits from A and B and both inherit from Base, super inside A calls B, not Base. That's what makes cooperative inheritance work: if every class calls super, each method in the chain runs exactly once."
class Base:
def hello(self): print("Base")
class A(Base):
def hello(self): print("A"); super().hello()
class B(Base):
def hello(self): print("B"); super().hello()
class C(A, B):
def hello(self): print("C"); super().hello()
C().hello() # C, A, B, Base
print([k.__name__ for k in C.__mro__]) # ['C', 'A', 'B', 'Base', 'object']
Saying super() always calls the parent class, or that Python searches depth-first and visits Base twice.
**kwargs in __init__ and pass them on to super()?@classmethod and a @staticmethod? When do you use each?Instance method: gets self; works with one object's data.
classmethod: gets cls; used for alternative constructors and works correctly for subclasses.
staticmethod: gets neither; a plain function kept inside the class for organisation.
"A normal method gets self, the instance, so it can read and change that object's attributes. A classmethod gets cls, the class it was called on, instead of an instance. The classic use is an alternative constructor, like Date.from_string that parses text and then returns cls of the parts. Because it uses cls rather than naming the class directly, a subclass calling it gets an instance of the subclass. A staticmethod gets nothing automatically. It's just a function that lives in the class because it belongs there logically, like a small validation helper. If a static method starts needing the class, I make it a classmethod, and if a helper doesn't relate to the class at all, I move it out to the module."
Not being able to give a real use for classmethod, or saying staticmethod receives the class.
return cls(...) better than return Date(...) in an alternative constructor?Measure: a profiler or timings, not a guess.
Cause: the specific hot spot and why it was slow in Python terms.
Result: the change, before and after, and how you checked the output didn't change.
"At my last company we had a nightly script that matched new orders against a list of known customers, and it had crept up to about forty minutes. Instead of guessing, I ran it under cProfile and sorted by cumulative time. Almost everything was inside one function doing if email in known_emails, where known_emails was a list with a few hundred thousand entries. Each check scanned the whole list, so the job was effectively quadratic. I built a set once before the loop, which made each lookup constant time on average. The profile also showed we parsed the same date strings over and over, so I cached that with functools.lru_cache. The run dropped to under two minutes. I diffed the output files from the old and new versions on a week of data to prove the results were identical."
A speed-up story with no measurement, or rewriting everything in another language before finding the hot spot.
Symptom: how you noticed, such as restarts, alerts or a growing graph.
Hunt: compare memory snapshots over time to see which objects and lines grow.
Fix and guard: remove the reference that kept things alive and add a check so it can't come back.
"At my last company, a background worker that processed uploaded files was being killed every few hours because its memory kept climbing. I reproduced it locally by replaying a batch of jobs, then used tracemalloc to take a snapshot early and another after a few hundred jobs. Comparing them showed almost all the growth came from one line that stored parsed results in a module-level dictionary. Someone had added it as a cache for retries, keyed by job id, and nothing ever removed entries. So it wasn't a garbage collector problem; the objects were still reachable. I replaced it with a small bounded cache that drops the oldest entries, and cleared a job's entry once it finished. Memory stayed flat after that. I also added a memory metric to our dashboard with an alert on steady growth."
Fixing it by scheduling restarts or calling gc.collect() in a loop, without ever finding what held the objects.
Bug: what happened and why the language didn't stop it.
Fix: the immediate repair and how you confirmed it.
Safeguard: the process or tooling change that stops the whole class of bug.
"At my last company I was bitten by None. A function that looked up a customer's discount returned None when there was no discount, and the caller subtracted it from the price. That path only happened for new customers with no history, so it passed our tests and then threw a TypeError in production. The quick fix was to return zero for no discount and add a test for a brand-new customer. The bigger change was that I pushed to add type hints to that module and run mypy in CI. Once the other lookup functions were honestly annotated as returning float | None, the checker flagged two callers with the same problem before they ever failed. We then turned on the rule for every new module."
Blaming the language or a teammate, or a story where nothing changed after the fix.
Diagnose: check which interpreter and environment actually run, and which package versions are installed.
Fix now: install the missing or mismatched package into that environment with python -m pip.
Prevent: one virtual environment per project, pinned versions in a lock or requirements file, the same Python version everywhere, and CI that installs from scratch.
"First I'd find out what's really running on the server. I'd print sys.executable and the Python version, and run python -m pip list with that same interpreter, because a very common cause is pip installing into one Python while the script runs under another. Then I'd compare the package versions with my laptop. Often the package is there but a different version is missing the function we import. To fix it now, I'd install the right version into the server's virtual environment. To stop it happening again, every project gets its own virtual environment, dependencies get pinned to exact versions in a lock file or requirements file generated from a known-good setup, and the server uses the same Python version as development. Finally, CI should build a fresh environment from that file and run the tests, so a missing dependency fails there first."
Installing packages globally with admin rights until it works, with no pinning and no idea why it broke.
python -m pip install safer than a bare pip install?ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.