Debugging • Performance • Concurrency • Reliability • Judgement Calls • 2026

Scenario-Based Python Interview Questions

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.

Debugging 4 questions

Medium Technical round Fresher, Mid-level Practice question

1. A function validates a batch of records, then saves them. Validation passes, but the save step writes nothing and raises no error. What do you check first?

What the interviewer is really testing:
Whether you recognise a one-shot iterator being used up by the first loop, a quiet bug that raises nothing.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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)
Red flag to avoid:

Blaming the database or adding retries before checking whether the second loop ever received any items.

They may ask next:
  • How would you make this function fail loudly if someone passes an iterator by mistake?
  • When does itertools.tee help, and when does it just hide the memory cost?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

2. You build a 3 by 3 game board with [[0] * 3] * 3. Setting one cell changes a whole column. What's going on, and how do you fix it?

What the interviewer is really testing:
Whether you see that multiplying the outer list repeats one reference, so all three rows are the same object.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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]]
Red flag to avoid:

Calling it a Python bug, or reaching for a deep copy without seeing that the rows were never separate.

They may ask next:
  • Why is [0] * 3 safe when [[]] * 3 is not?
  • Would copy.copy(board) fix the broken version?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

3. A report's rows come out in a different order every run, even with the same input. The code loops over a set of names. What's happening, and does it matter?

What the interviewer is really testing:
Whether you know set order isn't guaranteed and string hashes are salted per process, and why that breaks tests and diffs.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Pinning the hash seed and moving on, without making the order explicit in the code.

They may ask next:
  • Why doesn't a dict have the same problem, when it also uses hashes?
  • Where else can an unstated order sneak into output, like os.listdir or glob?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

4. A cleanup loop removes invalid emails with for e in emails: if bad(e): emails.remove(e). Some invalid ones survive. Why?

What the interviewer is really testing:
Whether you understand that the loop walks the list by position, so removing an item makes it skip the next one.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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]
Red flag to avoid:

Blaming the bad function without noticing the list is being changed while it's looped over.

They may ask next:
  • Why does looping over list(emails) fix it?
  • If the list is too big to copy, how would you remove the bad items in place in one pass?
Say it in 60 seconds

Imports and Modules 2 questions

Medium Technical round Mid-level Practice question

5. After you add one import to models.py, the app won't start: 'cannot import name User from partially initialized module'. How do you work through it?

What the interviewer is really testing:
Whether you understand how Python runs a module on import and can break a cycle on purpose rather than by shuffling lines.
Answer frame:

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.

Sample spoken answer:

“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:.”

Red flag to avoid:

Moving import lines around until the app happens to start, without being able to say where the cycle is.

They may ask next:
  • Why does import models survive a cycle where from models import User fails?
  • How would you stop new import cycles creeping back in?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

6. Your script fails with 'module random has no attribute randint', though that function has been there forever. What's the first thing you check?

What the interviewer is really testing:
Whether you know how Python searches for modules, and that a file of your own can shadow the standard library.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Reinstalling Python or the package before checking which file was actually imported.

They may ask next:
  • How can you see the exact list of places Python searches for modules?
  • Why can running a file with python -m change which module gets picked up?
Say it in 60 seconds

Errors and Reliability 4 questions

Medium Technical round Mid-level Practice question

7. A recursive function that walks nested JSON works in testing but crashes with RecursionError on one customer's data. What do you do?

What the interviewer is really testing:
Whether you know Python has a recursion limit and no tail call optimisation, and can tell a cycle from genuinely deep data.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
def walk(root):
    stack = [root]
    while stack:
        node = stack.pop()
        yield node
        stack.extend(node.get("children", []))
Red flag to avoid:

Setting the recursion limit to a huge number and calling it fixed.

They may ask next:
  • How would you keep the same visiting order as the recursive version?
  • How do you detect a cycle if the nodes carry no ids?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

8. A nightly job fails, and the only log line is Error: 'user_id'. You can't tell which line or which record caused it. What do you change?

What the interviewer is really testing:
Whether you know that logging str(e) throws away the exception type and traceback, and how to log failures so they can be traced.
Answer frame:

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?

Sample spoken answer:

“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.”

Code:
failures = 0
for record in records:
    try:
        handle(record)
    except Exception:
        logger.exception("failed on record %s", record.get("id"))
        failures += 1
Red flag to avoid:

Adding print statements and rerunning the job instead of fixing how errors are logged.

They may ask next:
  • Where would you still re-raise after logging, and why?
  • How do you stop one error being logged three times as it bubbles up?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

9. A long-running Python service starts failing after a few days with 'OSError: Too many open files'. A restart fixes it for a while. How do you find the leak?

What the interviewer is really testing:
Whether you can trace a resource leak to files or sockets that are opened and never closed, and prove it with numbers.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Raising the open file limit and calling it fixed.

They may ask next:
  • Why can relying on garbage collection to close files work on one Python and fail on another?
  • How would you find the exact line that opened the leaked handles?
Say it in 60 seconds
Hard Situational round Mid-level, Senior Practice question

10. Every deploy kills a worker mid-job, and it leaves half-written output files that the next step then reads. How do you make the worker safe to stop?

What the interviewer is really testing:
Whether you can handle a termination signal and write files so readers never see a partial result.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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)
Red flag to avoid:

Doing real work inside the signal handler, or counting on the next step to notice broken files.

They may ask next:
  • Why does the temp file need to be in the same folder as the final file?
  • What should the worker do if one item takes longer than the deploy tool is willing to wait?
Say it in 60 seconds

Performance and Memory 4 questions

Medium Case round Fresher, Mid-level Practice question

11. A matching script took seconds on a thousand rows and now takes hours on two hundred thousand. Nobody changed the code. Where do you look?

What the interviewer is really testing:
Whether you spot quadratic behaviour hiding in plain-looking code, and measure before you rewrite.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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]
Red flag to avoid:

Jumping to multiprocessing or a bigger machine without measuring where the time goes.

They may ask next:
  • What does a set need from its items, and what breaks if they're dicts?
  • How do you read a cProfile report to find the one slow line?
Say it in 60 seconds
Medium Case round Fresher, Mid-level Practice question

12. A service keeps the last ten thousand events in a list for a live dashboard, using append and pop(0). CPU climbs as traffic grows. What would you change?

What the interviewer is really testing:
Whether you know list.pop(0) shifts every remaining element, and reach for a bounded collections.deque.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
from collections import deque

recent = deque(maxlen=10_000)
recent.append(event)  # the oldest one falls off automatically
Red flag to avoid:

Proposing a database or a message broker before trying the right built-in structure.

They may ask next:
  • What does maxlen do when the deque is full and you call appendleft?
  • How would you keep an average over the window without rescanning it?
Say it in 60 seconds
Medium Case round Mid-level Practice question

13. Fifty million transactions stream in from a file, and you need the twenty largest by amount. Memory is tight. How do you do it?

What the interviewer is really testing:
Whether you avoid loading and sorting everything, and know that a small heap keeps only the best items seen so far.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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"]))
Red flag to avoid:

Reading the whole file into memory, sorting it and taking the last twenty.

They may ask next:
  • How would you change it if the top twenty must be reported every minute as new data keeps arriving?
  • Why is a min-heap, not a max-heap, the right shape here?
Say it in 60 seconds
Hard Case round Mid-level, Senior Practice question

14. A job loads ten million small records into instances of a plain class and runs out of memory, though the file is only a few hundred megabytes. What are your options?

What the interviewer is really testing:
Whether you know where per-object overhead comes from in Python and can choose between slots, tuples, arrays or streaming.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
from dataclasses import dataclass

@dataclass(slots=True)  # Python 3.10+
class Reading:
    sensor_id: int
    ts: float
    value: float
Red flag to avoid:

Asking for a bigger machine before checking whether all the records need to be in memory at once.

They may ask next:
  • What do you give up when a class uses __slots__?
  • How would you measure the memory each record really takes?
Say it in 60 seconds

Concurrency 3 questions

Hard Technical round Mid-level, Senior Practice question

15. You move a slow job onto multiprocessing.Pool, and it fails with 'Can't pickle local object'. The same code worked with threads. What's going on?

What the interviewer is really testing:
Whether you know that processes pass work to each other by pickling, and what can and can't cross that boundary.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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)
Red flag to avoid:

Switching back to threads for a CPU-bound job just to make the error go away.

They may ask next:
  • Why can the same script behave differently on Linux and macOS here?
  • When does the cost of sending data make processes slower than a single process?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

16. In an async service you start work with asyncio.create_task(send_email(user)) and never await it. Some emails never go out, and nothing is logged. Why?

What the interviewer is really testing:
Whether you know the event loop holds only weak references to tasks, and that errors in tasks nobody awaits are easy to lose.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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
Red flag to avoid:

Assuming a created task always runs to the end and that its errors show up like normal exceptions.

They may ask next:
  • How does asyncio.TaskGroup change what happens when one task fails?
  • What should happen to pending background tasks when the service shuts down?
Say it in 60 seconds
Medium Technical round Mid-level Practice question

17. Several threads add to a shared counter, and the final total comes out lower than it should. A teammate says the GIL makes that impossible. Who's right?

What the interviewer is really testing:
Whether you know the GIL protects the interpreter's own internals, not your read, change and write steps.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
import threading

lock = threading.Lock()
count = 0

def work(n):
    global count
    for _ in range(n):
        with lock:
            count += 1
Red flag to avoid:

Saying the GIL makes all Python code thread-safe.

They may ask next:
  • Which operations on a list or dict are safe between threads, and why is leaning on that risky?
  • How would you write a test that makes this race show up reliably?
Say it in 60 seconds

Design and Security 3 questions

Medium Situational round Mid-level, Senior Practice question

18. Two teammates disagree: one wants 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?

What the interviewer is really testing:
Whether you weigh an API choice by how callers actually use it, not by taste, and can change it without breaking them.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Picking one option out of habit without looking at how callers use the function.

They may ask next:
  • How would you change the function's behaviour without breaking all forty callers at once?
  • Where does an Optional type hint stop protecting you?
Say it in 60 seconds
Medium Situational round Mid-level, Senior Practice question

19. A teammate wants users to upload saved settings as pickle files, so the app can load them straight back into objects. It's quick to build. Do you approve it?

What the interviewer is really testing:
Whether you know that unpickling untrusted data can run arbitrary code, and can offer a safe way to keep the feature.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Approving it because the users are logged in, or not knowing that loading a pickle can execute code.

They may ask next:
  • Is signing the pickle file with a secret key enough to make it safe?
  • Besides pickle, what else in Python can run code while loading data?
Say it in 60 seconds
Easy Situational round Fresher, Mid-level Practice question

20. You find a small endpoint that uses eval() to work out formulas users type, like price * 1.2. It works and nobody has complained. What do you do?

What the interviewer is really testing:
Whether you recognise eval on user input as code execution, and know a safer way to meet the real need.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Leaving it because it works, or trying to strip dangerous words out of the input.

They may ask next:
  • Why isn't passing an empty dict for __builtins__ enough to make eval safe?
  • How would you stop a valid formula that takes forever, like a huge power?
Say it in 60 seconds

Text and Data Types 3 questions

Easy Technical round Fresher, Mid-level Practice question

21. A check if installed_version < "9.2": starts telling users on version 10.0 to upgrade. What went wrong, and how do you fix it?

What the interviewer is really testing:
Whether you notice that strings compare character by character, not as numbers.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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
Red flag to avoid:

Patching it with a special case for version 10 instead of fixing how versions are compared.

They may ask next:
  • How does tuple comparison decide between (1, 2) and (1, 2, 0)?
  • Where else does sorting strings give a surprising order, like file names with numbers in them?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

22. Code that reads from a network socket crashes with a bytes-like object is required, not 'str' on the line if "OK" in reply:. What's going on?

What the interviewer is really testing:
Whether you understand the line between bytes and text in Python 3, and where decoding belongs.
Answer frame:

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.

Sample spoken answer:

“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.”

Code:
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
Red flag to avoid:

Wrapping values in str() everywhere, which turns the bytes into the text "b'OK'" and hides the real problem.

They may ask next:
  • What does errors="replace" do when decoding, and when is it a bad idea?
  • How would you read whole lines from a socket without splitting characters?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

23. A lookup 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?

What the interviewer is really testing:
Whether you check types at the boundary instead of trusting what a print shows, since the string 42 and the number 42 are different keys.
Answer frame:

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.

Sample spoken answer:

“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.”

Red flag to avoid:

Wrapping the lookup in try and except with a default, which hides every order that hits the bug.

They may ask next:
  • Why do 1, 1.0 and True all land on the same dict key?
  • Where else do ids quietly arrive as strings?
Say it in 60 seconds
Were you asked something else? Share it A person checks every question before it goes on the site. No name is shown.
For the call itself

You practiced these. On the real call, ClapAssist helps with the rest.

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.

Download with 10 free minutes
Mac and Windows · Stays out of screen share · No card