Scenario-based Python interviews describe a situation instead of asking for a definition: a job that got slow, a loop that skips items, a service that runs out of file handles, a teammate who wants to load pickle uploads. Every question here is a concrete scenario. The answers show the order of checks, say out loud what to look at first, and name what would change your mind. It is written for anyone facing this kind of Python round. Practice by reading only the question, talking through your own plan, then comparing it with the sample. For core concepts like generators, decorators and the GIL, start with the main Python page.
Search all questions by round, difficulty and level, or save the ones you want to practice.
First suspect: the input is a generator or other iterator, and the validation loop already consumed it.
Confirm: log type(records) and count the items each loop actually sees.
Fix: call list() once if the batch fits in memory, or validate and save in a single pass.
“No error and zero rows saved makes me think the data was there once and then gone. My first guess is that records is a generator, maybe from a file reader or a map call, and the validation loop walked it to the end. A generator can only be iterated once, so the second loop gets nothing and finishes silently. I'd confirm by logging the type of records and counting items in each loop. If the batch fits in memory, I'd turn it into a list once at the top. If it's huge, I'd validate and save in one pass, row by row. What would change my mind is if the first loop also saw zero items, because then the problem is upstream, in whatever produces the records.”
def process(records):
records = list(records) # a generator would be empty on the second loop
for r in records:
validate(r)
for r in records:
save(r)
Blaming the database or adding retries before checking whether the second loop ever received any items.
itertools.tee help, and when does it just hide the memory cost?[[0] * 3] * 3. Setting one cell changes a whole column. What's going on, and how do you fix it?Symptom: a change in one row shows up in every row at the same position.
Cause: * 3 on the outer list stores the same inner list three times.
Fix: build each row separately with a comprehension, and prove it with is.
“The outer * 3 doesn't make three rows. It makes a list that holds the same inner list three times. So when I set board[0][1] = 1, I'm changing that one shared row, and because every row is that row, the value shows up in the same column everywhere. I can prove it in a second: board[0] is board[1] returns True. The fix is to create a new inner list for each row, with a comprehension. The inner [0] * 3 is fine, because integers are immutable, so repeating them is harmless. The same trap shows up with dict.fromkeys(keys, []), where every key ends up sharing one list.”
board = [[0] * 3] * 3
board[0][1] = 1
print(board) # [[0, 1, 0], [0, 1, 0], [0, 1, 0]]
print(board[0] is board[1]) # True
board = [[0] * 3 for _ in range(3)]
board[0][1] = 1
print(board) # [[0, 1, 0], [0, 0, 0], [0, 0, 0]]
Calling it a Python bug, or reaching for a deep copy without seeing that the rows were never separate.
[0] * 3 safe when [[]] * 3 is not?copy.copy(board) fix the broken version?Why: set order follows hashes, and string hashes are salted afresh each time the interpreter starts.
Why ints look stable: small integers hash to themselves, which hides the problem until the data is strings.
Fix: sort explicitly where order matters, or dedupe with a dict, which keeps insertion order.
“Sets have no defined order, and for strings it's worse than it sounds. Python salts string hashes with a random value each time the process starts, as a defence against inputs crafted to cause lots of collisions. So the same set of names iterates in a different order on every run. With small integers you might never notice, because they hash to themselves and the order looks stable. It matters the moment anyone relies on it: tests comparing output, report diffs where every line looks changed, or cached output that never matches. The fix is to decide which order we actually want. Alphabetical means sorted(names). First-seen means deduping with dict.fromkeys(names), since dicts keep insertion order. Fixing PYTHONHASHSEED would hide it, but that's papering over the real issue.”
Pinning the hash seed and moving on, without making the order explicit in the code.
os.listdir or glob?for e in emails: if bad(e): emails.remove(e). Some invalid ones survive. Why?Cause: removing the current item shifts the next one into its place, and the loop moves past it.
Spot it: two bad items in a row is exactly the case that survives.
Fix: build a new list with a comprehension; use emails[:] = ... if other code holds the same list.
“The for loop walks the list by position. Say position one is bad and I remove it. Everything after it shifts left, so the item that was at position two is now at one, and the loop moves on to position two and never looks at it. That's why two bad emails in a row is the case that slips through. There's no error, which makes it nasty. The clean fix is to build a new list with a comprehension that keeps only the good ones. If other code holds a reference to the same list and needs to see the change, I'd assign to emails[:] to replace the contents in place. And remove searches from the start every time, so the original is slow on big lists anyway.”
emails = ["a@x.com", "bad", "worse", "b@x.com"]
for e in emails:
if "@" not in e:
emails.remove(e)
print(emails) # ['a@x.com', 'worse', 'b@x.com']
emails[:] = [e for e in emails if "@" in e]
Blaming the bad function without noticing the list is being changed while it's looped over.
list(emails) fix it?models.py, the app won't start: 'cannot import name User from partially initialized module'. How do you work through it?Read the trace: it shows the loop, for example models imports services, which imports models again.
Why it fails: the second import gets the half-run module from sys.modules, before User is defined.
Fix: move the shared piece into a third module, import the module instead of the name, or import inside the function as a last resort.
“That message means a circular import. When Python imports a module, it registers it in sys.modules and runs it top to bottom. If models imports services near the top, and services does from models import User, Python hands back the half-finished models module, User isn't defined yet, and it fails. I'd read the traceback bottom up to see the exact loop. The cleanest fix is usually to pull whatever both modules need into a third module, so dependencies only point one way. Switching to import models and using models.User inside functions also works, because the name is looked up later, at call time. Importing inside a function is my last resort. If the import is only there for type hints, I'd put it under if TYPE_CHECKING:.”
Moving import lines around until the app happens to start, without being able to say where the cycle is.
import models survive a cycle where from models import User fails?First suspect: a file named random.py next to the script, which Python finds before the standard library.
Confirm: print(random.__file__) shows which file was actually imported.
Fix: rename the local file, and look for other clashes like email.py, json.py or a variable called list.
“When a standard module seems to be missing a function it definitely has, I assume I'm not importing the module I think I am. The script's own folder comes first on Python's search path, so if there's a file called random.py sitting next to it, maybe an old practice file, Python imports that instead. I'd confirm with print(random.__file__), which shows the exact file that was loaded. The fix is to rename my file to something that doesn't clash. The same thing happens with files called email.py, json.py or test.py, and with variables too: naming a variable list or str breaks calls to those built-ins later in the same scope, with an equally confusing error.”
Reinstalling Python or the package before checking which file was actually imported.
python -m change which module gets picked up?Check the data: straight from json.loads it can't loop, so it's genuinely deep; if our code built or linked it, look for a cycle, like a node that points back to its parent.
Why it fails: each call uses a stack frame, the default limit is around a thousand, and Python doesn't optimise tail calls.
Fix: rewrite with an explicit stack; raising the limit is a stopgap that can crash the whole process.
“First I'd find out whether the data really is that deep or whether it loops. If it came straight from json.loads, it can't contain a cycle, so it's genuinely deep. If our code built it or linked nodes to their parents, a cycle recurses forever and the limit just catches it, so I'd track what I've visited, by id. For genuinely deep data, the issue is that Python caps recursion, around a thousand frames by default, and it doesn't do tail call optimisation. So I'd rewrite the walk with an explicit stack: push the root, pop an item, process it, push its children. That handles any depth that fits in memory. I wouldn't just crank up sys.setrecursionlimit, because the real C stack can overflow and kill the process with no Python traceback at all.”
def walk(root):
stack = [root]
while stack:
node = stack.pop()
yield node
stack.extend(node.get("children", []))
Setting the recursion limit to a huge number and calling it fixed.
Error: 'user_id'. You can't tell which line or which record caused it. What do you change?Why it's useless: str(e) of a KeyError is just the missing key, with no type, no line and no record.
Log properly: logger.exception(...) inside the except block records the full traceback; put the record's id in the message.
Decide: should one bad record stop the job, or be logged, counted and skipped?
“That line is almost certainly a KeyError where someone logged str(e), which for a KeyError is only the missing key in quotes. The type and the traceback are gone. First I'd change the handler to use logger.exception with the record's id in the message. That logs the message plus the full traceback, so I get the file, the line and the record. Then I'd decide how the job should behave. If one bad record shouldn't kill the whole run, I'd catch errors per record, log each with its id, count them, and fail the job at the end if too many went wrong. For this bug itself, I'd look at that record, see why user_id was missing, and validate input where it enters, so the next failure is a clear message instead of a bare key name.”
failures = 0
for record in records:
try:
handle(record)
except Exception:
logger.exception("failed on record %s", record.get("id"))
failures += 1
Adding print statements and rerunning the job instead of fixing how errors are logged.
Confirm the leak: watch the process's open handles over time (lsof -p or /proc/<pid>/fd on Linux) and see which kind piles up.
Find the code: look for open() outside with, a new HTTP client or session per request, and pipes never closed.
Fix and guard: use with everywhere, share one client, and run tests in dev mode so unclosed files raise warnings.
“A restart that fixes it for a few days is a classic leak, so first I'd measure it. On Linux I'd count the entries in the process's fd folder under /proc every few minutes, or run lsof -p on it, and look at what's piling up: regular files, sockets to one host, or pipes. That tells me where to look. If it's files, I search for open() calls that aren't in a with block, especially on error paths where an exception skips the close. If it's sockets, a common cause is creating a new HTTP client for every request and never closing it. I'd fix it with with blocks and one shared client. Raising the open file limit only buys time. To catch it early, running the tests with python -X dev shows a ResourceWarning whenever a file is cleaned up without being closed.”
Raising the open file limit and calling it fixed.
Atomic writes: write to a temp file in the same folder, flush, then os.replace it onto the final name.
Handle SIGTERM: the handler only sets a flag; the loop checks it between items, finishes the current one and exits.
Safe reruns: design each job so running it twice gives the same result.
“Two separate fixes. First, readers should never see a half-written file, whatever kills the worker. So I'd write output to a temporary file in the same directory, flush and fsync it, then call os.replace onto the real name. On the same file system that swap is atomic on Linux and macOS, so the next step sees either the old file or the complete new one. Second, the worker should stop politely. Deploy tools usually send SIGTERM and wait a while before a hard kill. I'd install a handler that just sets a flag, have the main loop check it between items, finish the current item and exit cleanly. And since a hard kill can still happen, I'd make every job safe to rerun, so a restart simply redoes the unfinished item.”
import os, signal, tempfile
stopping = False
def on_term(signum, frame):
global stopping
stopping = True
signal.signal(signal.SIGTERM, on_term)
def write_atomic(path, data):
folder = os.path.dirname(path) or "."
with tempfile.NamedTemporaryFile("w", dir=folder, delete=False) as tmp:
tmp.write(data)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp.name, path)
Doing real work inside the signal handler, or counting on the next step to notice broken files.
Read the shape: 200 times the data taking thousands of times longer points to something quadratic.
Measure: run cProfile on a medium sample and sort by cumulative time.
Usual cause: if x in some_list inside a loop, or a query per row; swap in a set or dict, or batch the calls.
“Two hundred times the rows but thousands of times the run time tells me it's roughly quadratic, not just more work. Before guessing, I'd profile it on about twenty thousand rows with cProfile and sort by cumulative time. Very often in a matching script it's a membership check like if customer_id in seen_ids, where seen_ids is a list. Each check scans the whole list, so the loop is n times n. Turning that list into a set makes each lookup constant time on average, and the script goes back to seconds. If the profile pointed somewhere else, like a database call for every row, I'd batch those calls instead. Either way, I'd add a timing check on a bigger sample so it can't creep back unnoticed.”
seen_ids = set(existing_ids) # was a list: every `in` scanned all of it
matches = [row for row in rows if row["customer_id"] in seen_ids]
Jumping to multiprocessing or a bigger machine without measuring where the time goes.
append and pop(0). CPU climbs as traffic grows. What would you change?Cost: pop(0) moves every remaining item one slot left, so each event costs time in proportion to the window size.
Swap: deque(maxlen=10_000) drops the oldest item on its own, in constant time.
Check the readers: deque indexing is slow in the middle, so if the dashboard indexes, keep running totals instead.
“A list is fast at the end and slow at the front. Every pop(0) shifts all the remaining items left by one, so with ten thousand items each new event does ten thousand moves, and that cost grows with traffic. I'd switch to collections.deque with a maxlen of ten thousand. Appending then drops the oldest item automatically, and both ends are constant time. Before swapping, I'd check how the dashboard reads the window. If it only loops over it or reads the newest items, deque is a drop-in. If it indexes into the middle, deque is slower there, so I'd keep running totals as events arrive instead of rescanning. If several threads touch it, single appends are safe, but reading several items together still needs a lock.”
from collections import deque
recent = deque(maxlen=10_000)
recent.append(event) # the oldest one falls off automatically
Proposing a database or a message broker before trying the right built-in structure.
maxlen do when the deque is full and you call appendleft?Don't sort it all: reading every row into a list and sorting costs memory and n log n time just to keep twenty.
Keep a small heap: a min-heap of size twenty; each new row replaces the smallest if it's bigger.
In Python: heapq.nlargest(20, rows, key=...) does exactly this over any iterable, one row at a time.
“I wouldn't load fifty million rows into a list and sort them. That's a lot of memory and n log n work just to keep twenty. I'd read the file lazily, one row at a time, and keep a min-heap of the twenty largest seen so far. For each row, if the heap has fewer than twenty items I push it, otherwise if its amount beats the smallest in the heap, I replace that one. That's n log 20, basically linear, with only twenty rows held. In Python, heapq.nlargest does exactly that and accepts a generator, so the code is two lines. I'd settle what happens with ties and missing amounts first. And if the data already sat in a database, I'd let the database sort and limit it.”
import csv, heapq
from decimal import Decimal
with open("transactions.csv", newline="") as f:
rows = csv.DictReader(f)
top = heapq.nlargest(20, rows, key=lambda r: Decimal(r["amount"]))
Reading the whole file into memory, sorting it and taking the last twenty.
Why it balloons: each instance has an object header and its own __dict__, and every field is a separate object.
Cheaper shapes: __slots__ or @dataclass(slots=True) drops the per-instance dict; plain tuples are smaller still.
Bigger win: do you need all ten million at once? Stream and aggregate, or store numeric columns as arrays.
“A few hundred megabytes on disk can easily become several gigabytes in Python, because each record becomes an object with a header and its own __dict__, and each field is a separate int or string object. First I'd ask whether we need all ten million in memory at once. Often we're summing or grouping, and a generator that aggregates while it reads solves it outright. If we do need them all, adding __slots__, or slots=True on a dataclass, removes the per-instance dict and saves a lot. Plain tuples are smaller again. For numeric columns, the array module or a numeric library stores raw values instead of objects. I'd measure with tracemalloc on a sample of a hundred thousand records before and after, rather than guess.”
from dataclasses import dataclass
@dataclass(slots=True) # Python 3.10+
class Reading:
sensor_id: int
ts: float
value: float
Asking for a bigger machine before checking whether all the records need to be in memory at once.
__slots__?multiprocessing.Pool, and it fails with 'Can't pickle local object'. The same code worked with threads. What's going on?Why: each worker is a separate process, so the function and its arguments are pickled and sent over; threads share memory and skip that.
What won't pickle: lambdas, functions defined inside other functions, open files, locks and database connections.
Fix: use a top-level worker function, pass plain data, open connections inside the worker, and start the pool under the __main__ guard.
“Threads share one memory space, so any function works. Processes don't. The pool has to pickle the function and every argument and send them to the worker, and pickle can only handle a function by its importable name. A lambda or a function defined inside another function has no such name, hence the error. So I'd move the worker to the top level of a module. Then I'd check the arguments: a database connection, an open file or a lock won't pickle either, so each worker should open its own connection. I'd also create the pool under if __name__ == "__main__":, because where workers are spawned fresh, each one re-imports the main module. And I'd pass small inputs like ids, since pickling big objects can eat the whole speed-up.”
from multiprocessing import Pool
def resize(path): # top level, so it pickles by name
...
if __name__ == "__main__":
with Pool() as pool:
results = pool.map(resize, paths)
Switching back to threads for a CPU-bound job just to make the error go away.
asyncio.create_task(send_email(user)) and never await it. Some emails never go out, and nothing is logged. Why?Two causes: the task can be garbage collected mid-flight because nothing holds it, and its exception only surfaces as a late warning.
Check: look for 'Task exception was never retrieved' or 'Task was destroyed but it is pending', and whether deploys stop the process mid-task.
Fix: keep tasks in a set with a done callback that logs errors, or use a real job queue for work that must happen.
“Fire and forget with create_task has two traps. First, the event loop only keeps a weak reference to a task, so if my code doesn't hold on to it, it can be garbage collected before it finishes, and the work just stops. Second, if send_email raises, nobody awaits the task, so the exception only shows up as a 'Task exception was never retrieved' warning when the task is cleaned up, which is easy to miss. I'd also check whether deploys stop the process while tasks are still pending. The fix is to keep each task in a set and add a done callback that removes it and logs any exception. But if an email truly must go out, I'd put it on a proper job queue with retries instead of trusting an in-memory task.”
background = set()
def _report(task):
background.discard(task)
if not task.cancelled() and task.exception():
log.error("background task failed", exc_info=task.exception())
def fire(coro):
task = asyncio.create_task(coro)
background.add(task)
task.add_done_callback(_report)
return task
Assuming a created task always runs to the end and that its errors show up like normal exceptions.
asyncio.TaskGroup change what happens when one task fails?Cause: count += 1 is a read, an add and a write; a thread switch between them loses an update.
The GIL's job: it keeps the interpreter's objects safe one bytecode at a time, not your multi-step logic.
Fix: guard the update with a threading.Lock, or count per thread and add the results at the end.
“The low total is real, and the teammate is mixing up two things. The GIL means only one thread runs Python bytecode at a time, which keeps the interpreter's own objects safe. But count += 1 is several steps: load the value, add one, store it back. The interpreter can switch threads between those steps, so two threads both read 41, both write 42, and one increment is lost. Whether you see it depends on the Python version and timing, which is exactly why it's dangerous: it passes tests and fails under load. I'd wrap the increment in a threading.Lock. If it's a hot path, I'd let each thread count locally and add the totals at the end, or send results through a queue.Queue so a single thread owns the counter.”
import threading
lock = threading.Lock()
count = 0
def work(n):
global count
for _ in range(n):
with lock:
count += 1
Saying the GIL makes all Python code thread-safe.
find_user() to return None when nobody matches, the other wants it to raise. It's called in forty places. How do you settle it?Ask the callers: is a missing user normal (a search) or a bug (an id we just stored)?
Let the name carry it: often both exist, find_user returns None and get_user raises a specific UserNotFound.
Make it safe: type the result as User | None so the type checker forces a check, and move callers over gradually.
“I'd stop it being a matter of taste and look at the forty callers. If most of them treat no match as normal, like a search box, returning None is honest, and I'd type it as User | None so the type checker makes every caller handle it. If most of them assume the user exists because the id just came from our own database, None is dangerous: it blows up three lines later as a confusing AttributeError. There, raising a specific UserNotFound fails right where the problem is. Quite often the answer is both, with find_user returning None and get_user raising, so the name tells callers which contract they get. What I'd avoid is raising a bare Exception or returning False, which hides the reason.”
Picking one option out of habit without looking at how callers use the function.
Say no, clearly: loading a pickle can run any code the file's author chose, so an upload becomes code execution on the server.
Offer the alternative: JSON, validated into a dataclass or schema on the way in.
Where pickle is fine: data only your own code writes and reads, like a local cache.
“I wouldn't approve it, and I'd explain why rather than just block it. Unpickling isn't just reading data. A pickle file can tell Python to call any function with any arguments while it loads, so a user could upload a file that runs a shell command on our server. There's no safe way to load an untrusted pickle. The feature itself is fine, though. I'd suggest saving settings as JSON, and on load, validating it into a dataclass or schema so unknown keys and wrong types get a clear error. That's not much extra work. Pickle is fine for things only our own code writes and reads, like a local cache file. I'd also search the codebase for other places that unpickle outside data, because this pattern tends to repeat.”
Approving it because the users are logged in, or not knowing that loading a pickle can execute code.
eval() to work out formulas users type, like price * 1.2. It works and nobody has complained. What do you do?The risk: eval runs any Python expression, so a user can import modules, read files or run commands.
Don't patch it: emptying the builtins or filtering characters has been bypassed again and again.
Replace it: parse the formula with ast and allow only numbers, your own variable names and arithmetic.
“Nobody complaining just means nobody has tried yet. eval runs whatever expression it gets, so a user could type something that imports os and deletes files or reads our secrets. I'd raise it as a security bug with a short example, not as a style comment. I wouldn't try to make eval safe by passing empty builtins or blocking certain characters, because people keep finding ways around that. What we actually need is arithmetic on a few known variables. So I'd parse the formula with the ast module and walk the tree, allowing only numbers, the variable names we provide, and the four basic operators, and rejecting everything else with a clear error. If it only needs literal values like numbers or lists, ast.literal_eval is enough.”
Leaving it because it works, or trying to strip dangerous words out of the input.
__builtins__ enough to make eval safe?if installed_version < "9.2": starts telling users on version 10.0 to upgrade. What went wrong, and how do you fix it?Cause: strings compare one character at a time, and '1' sorts before '9', so "10.0" counts as less than "9.2".
Fix: compare tuples of ints, or use a real version parser such as the packaging library for tags like rc1.
Prevent: add a test with a two-digit version, and look for other string comparisons of versions.
“Both values are strings, and Python compares strings one character at a time. The first characters are 1 and 9, and 1 comes first, so it decides 10.0 is less than 9.2 without looking any further. It worked for years because every version had a single-digit major number. The quick fix is to split on dots and compare tuples of integers, so it becomes ten, zero against nine, two, and tuples compare item by item as numbers. If versions can carry things like rc1 or post1, I'd use the Version class from the packaging library, which knows those rules. Then I'd add a test with a two-digit version and a pre-release, and look for other places comparing versions as strings, because it's rarely just one.”
def as_tuple(v):
return tuple(int(part) for part in v.split("."))
print("10.0" < "9.2") # True
print(as_tuple("10.0") < as_tuple("9.2")) # False
Patching it with a special case for version 10 instead of fixing how versions are compared.
(1, 2) and (1, 2, 0)?a bytes-like object is required, not 'str' on the line if "OK" in reply:. What's going on?Cause: sockets and binary files give bytes, "OK" is str, and Python 3 never mixes the two silently.
Where to fix: decode once, at the edge where data comes in, with the encoding the other side really uses.
Watch out: one read can end mid-character, so decode a complete message, not each chunk.
“In Python 3, bytes and text are different types. sock.recv returns bytes, and "OK" is a str, so the in check refuses to compare them. The quick patch is to check for b"OK", and for a simple protocol check that's actually fine. But if the code goes on to treat the reply as text, I'd decode it once, right where it comes in, with the encoding the server really uses, usually UTF-8, and keep everything inside the program as str. Then I'd encode only when sending. One catch with sockets: a single read can stop in the middle of a multi-byte character, so I'd collect a full message, by a length prefix or a newline, before decoding. Otherwise it works in testing and fails on the first name with an accent.”
reply = sock.recv(4096) # bytes
if b"OK" in reply: # compare bytes with bytes
...
text = reply.decode("utf-8") # or decode once, where data comes in
Wrapping values in str() everywhere, which turns the bytes into the text "b'OK'" and hides the real problem.
errors="replace" do when decoding, and when is it a bad idea?prices_by_id[order["product_id"]] raises KeyError for a product that's clearly in the dict. The ids come from a JSON request. What do you check?Check the type, not the value: print repr() of the key and of a key in the dict; a plain print looks the same for both.
Why: JSON and query strings often carry ids as strings, while the dict was built from integer database ids.
Fix: convert and validate once, where the request enters, not at every lookup.
“When a key is obviously there but the lookup fails, I stop looking at values and look at types. A plain print shows 42 either way, so I'd print the repr of the incoming id next to the repr of a key from the dict. Very often the request sends the id as the string 42, while the dict was built from database rows with integer ids, and in Python a string never equals an int, so they're different keys. A stray space or newline is the other usual suspect, and repr shows that too. I'd fix it where data enters the program: parse the request into a model or dataclass that turns product_id into an int once, with a clear error if it isn't a number. Converting at each lookup just spreads the problem around.”
Wrapping the lookup in try and except with a default, which hides every order that hits the bug.
1, 1.0 and True all land on the same dict key?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 live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.