Core Python • Data Types • Generators • Decorators • Concurrency • 2026

Python Interview Questions

30 questions What each one tests, an answer frame, a spoken answer 32 min read

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.

Data Types 2 questions

Easy Technical round Fresher Practice question

1. When would you pick a list, a tuple, a set or a dict in Python? Give a real reason for each.

What the interviewer is really testing:
Whether you choose a container for how the data is used, especially lookups and mutability, rather than out of habit.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Saying a tuple is just a faster list, or using a list for membership checks inside a loop without seeing the cost.

They may ask next:
  • Why can a tuple be a dict key when a list can't?
  • What happens to lookup speed if many keys have the same hash?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

2. Which built-in types are mutable, and what happens when a function changes an argument it was given?

What the interviewer is really testing:
Whether you understand that Python passes references to objects, so you can predict when a caller sees a change and when it doesn't.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Saying Python copies arguments, or that it is pass by reference like C++ references so reassignment affects the caller.

They may ask next:
  • Is Python pass by value or pass by reference?
  • If a tuple holds a list, can you use that tuple as a dict key?
Say it in 60 seconds

Memory & Internals 3 questions

Easy Technical round Fresher Practice question

3. What is the difference between is and ==, and when should you use is?

What the interviewer is really testing:
Whether you know identity from equality, and why comparing numbers or strings with is gives results that look random.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Using is to compare strings or numbers, or saying the two are interchangeable.

They may ask next:
  • Why is x == None considered worse than x is None?
  • Can two objects be equal but have different hashes, and what breaks if they do?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

4. What is the difference between a shallow copy and a deep copy, and when has the difference bitten you?

What the interviewer is really testing:
Whether you can predict what is shared after a copy of nested data, which is a common source of spooky bugs.
Answer frame:

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.

Sample spoken answer:

"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."

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

Thinking new = old makes a copy, or believing a shallow copy protects nested lists and dicts.

They may ask next:
  • How would you make a class control what happens when it is deep copied?
  • Why does [[0] * 3] * 3 behave strangely when you change one cell?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

5. How does Python manage memory? Explain reference counting and the garbage collector.

What the interviewer is really testing:
Whether you know when objects are actually freed in CPython, and why reference cycles need a separate collector.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Saying del x frees the object, or that Python has no garbage collector because it uses reference counting.

They may ask next:
  • What is a weak reference, and when would you use one?
  • Why might a process not give memory back to the operating system after objects are freed?
Say it in 60 seconds

Functions 5 questions

Medium Technical round Fresher, Mid-level Practice question

6. What is wrong with def add_tag(tag, tags=[]), and how do you fix it?

What the interviewer is really testing:
Whether you know that default values are evaluated once, when the function is defined, which is one of the most common real Python bugs.
Answer frame:

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.

Sample spoken answer:

"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."

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

Not spotting the bug, or fixing it with tags = tags or [], which also replaces an empty list the caller passed in on purpose.

They may ask next:
  • Is there ever a good reason to use a mutable default on purpose?
  • Where can you see a function's default values at runtime?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

7. What do *args and **kwargs do, and what are keyword-only and positional-only parameters?

What the interviewer is really testing:
Whether you can read and design function signatures, including the parts that protect an API from being called the wrong way.
Answer frame:

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.

Sample spoken answer:

"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."

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

Saying args and kwargs are special keywords, or not knowing which one is a tuple and which is a dict.

They may ask next:
  • Why is a function that takes only *args, **kwargs harder to use and to type-check?
  • What order must normal, star, keyword-only and double-star parameters appear in?
Say it in 60 seconds
Medium Coding round Fresher, Mid-level Practice question

8. Write a decorator that logs how long a function takes. What does functools.wraps add?

What the interviewer is really testing:
Whether you understand that functions are objects and a decorator is just a function that returns a replacement, and whether you keep the wrapped function's identity.
Answer frame:

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.

Sample spoken answer:

"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."

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

Forgetting to return the wrapped function's result, or a wrapper that only accepts a fixed number of arguments.

They may ask next:
  • How would you change this so it also works on async def functions?
  • In what order are two stacked decorators applied?
Say it in 60 seconds
Hard Coding round Mid-level, Senior Practice question

9. How would you build a decorator that takes arguments, like @retry(times=3, delay=1)?

What the interviewer is really testing:
Whether you can handle the extra layer a decorator factory needs, and whether your retry logic avoids retrying the wrong errors forever.
Answer frame:

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.

Sample spoken answer:

"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."

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

Catching every exception and retrying, or a loop that ends silently and returns None instead of raising.

They may ask next:
  • How would you let the decorator work both as @retry and as @retry(times=5)?
  • Why is retrying a payment call dangerous, and what makes a call safe to retry?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

10. What does [f() for f in [lambda: i for i in range(3)]] return, and why?

What the interviewer is really testing:
Whether you understand closures well enough to predict late binding, which shows up in real code as callbacks that all use the last loop value.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Guessing [0, 1, 2], or blaming lambdas when a normal nested def behaves exactly the same way.

They may ask next:
  • What does the nonlocal keyword do, and when do you need it?
  • Does a plain for loop that defines functions have the same problem?
Say it in 60 seconds

Idioms & Typing 3 questions

Easy Technical round Fresher, Mid-level Practice question

11. When is a comprehension the right tool, and when would you write a plain loop instead?

What the interviewer is really testing:
Whether you write idiomatic Python but still put readability first, and know the list, dict, set and generator forms.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Cramming nested conditions into one unreadable line to show off, or using a list comprehension only for its side effects.

They may ask next:
  • What is the difference between [x for x in data] and (x for x in data) in memory use?
  • How would you flatten a list of lists in one comprehension, and would you?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

12. Type hints aren't enforced at runtime, so why use them? How do you type something that might be None?

What the interviewer is really testing:
Whether you see type hints as a tool for catching bugs and documenting intent, and can write the common forms correctly.
Answer frame:

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.

Sample spoken answer:

"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."

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

Thinking hints are checked at runtime, or treating them as decoration and never running a checker.

They may ask next:
  • What is the difference between a Protocol and an abstract base class?
  • When would you reach for Any, and what does it cost you?
Say it in 60 seconds
Medium Coding round Fresher, Mid-level Practice question

13. Given a large text file, return the ten most common words. Keep memory use low.

What the interviewer is really testing:
Whether you reach for the standard library, stream the file instead of loading it, and think about what counts as a word.
Answer frame:

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.

Sample spoken answer:

"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."

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

Calling f.read().split() on a huge file, or hand-writing a counting dict when Counter exists.

They may ask next:
  • How would you find the top ten without sorting all the counts?
  • How would you split this work across several processes?
Say it in 60 seconds

Iterators & Generators 3 questions

Medium Technical round Fresher, Mid-level Practice question

14. What is a generator, and why would you return one instead of a list?

What the interviewer is really testing:
Whether you understand lazy evaluation and the memory it saves, and the catch that a generator can only be used once.
Answer frame:

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.

Sample spoken answer:

"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."

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

Saying generators are faster at everything, or not knowing that the body doesn't run until the first next.

They may ask next:
  • What happens if you loop over the same generator twice?
  • What does yield from do?
Say it in 60 seconds
Medium Technical round Mid-level Practice question

15. What makes an object iterable, and how is an iterable different from an iterator?

What the interviewer is really testing:
Whether you know the protocol behind every for loop, and why a list can be looped over many times while a file or generator can't.
Answer frame:

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.

Sample spoken answer:

"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."

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

Mixing up the two, or not knowing where StopIteration comes into a for loop.

They may ask next:
  • Why does calling next() directly on a list fail?
  • How would you peek at the first item of an iterator without losing it?
Say it in 60 seconds
Medium Coding round Fresher, Mid-level Practice question

16. Write a function that flattens an arbitrarily nested list of lists, yielding items lazily.

What the interviewer is really testing:
Whether you can combine recursion with generators cleanly, and think about the edge cases such as strings and very deep nesting.
Answer frame:

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.

Sample spoken answer:

"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."

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

Checking for any iterable and recursing endlessly on strings, or building and concatenating lists at every level.

They may ask next:
  • How would you rewrite this without recursion?
  • How would you change it to flatten any iterable except strings and bytes?
Say it in 60 seconds

Concurrency 4 questions

Medium Technical round Mid-level, Senior Practice question

17. What is the GIL, and what does it mean for using threads in Python?

What the interviewer is really testing:
Whether you know exactly what the GIL limits and what it doesn't, instead of repeating that Python threads are useless.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Saying threads never help in Python, or that the GIL means you never need locks.

They may ask next:
  • Why is counter += 1 not safe across threads even with the GIL?
  • Does PyPy or another interpreter have the same limitation?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

18. You need to resize ten thousand images, call two hundred slow APIs and serve many websocket clients. Threads, processes or asyncio for each?

What the interviewer is really testing:
Whether you match the concurrency model to the kind of work, CPU-bound or waiting on I/O, and know the costs of each.
Answer frame:

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.

Sample spoken answer:

"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?"

Red flag to avoid:

One answer for all three jobs, or threads for the CPU-heavy image work.

They may ask next:
  • Why might a process pool be slower than a plain loop for many tiny tasks?
  • How would you cap the number of API calls in flight at once with asyncio?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

19. What actually happens when a coroutine hits await, and what goes wrong if it calls a blocking function?

What the interviewer is really testing:
Whether you understand cooperative scheduling on one event loop, which is what separates people who have shipped asyncio code from people who have read about it.
Answer frame:

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.

Sample spoken answer:

"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."

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

Saying asyncio runs coroutines in parallel threads, or calling blocking libraries inside async def without noticing.

They may ask next:
  • What is the difference between awaiting two coroutines one after the other and using gather?
  • How would you find which coroutine is blocking the loop in a live service?
Say it in 60 seconds
Hard Situational round Mid-level, Senior Practice question

20. A teammate added threads to a CPU-heavy data job to speed it up, and it got slower. What do you tell them?

What the interviewer is really testing:
Whether you can explain the GIL in practical terms to a colleague and propose a measured fix, not just say threads are bad.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Telling them to just use more threads, or dismissing Python as too slow without measuring anything.

They may ask next:
  • How would you choose the chunk size for a process pool?
  • When would threads still be the right choice in a data job?
Say it in 60 seconds

Errors & Context 3 questions

Easy Technical round Fresher Practice question

21. Walk me through try, except, else and finally. When does each block run?

What the interviewer is really testing:
Whether you use exception handling precisely, keeping the protected block small and cleanup guaranteed.
Answer frame:

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.

Sample spoken answer:

"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."

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

Wrapping a whole function in one try with a bare except, or not knowing when else runs.

They may ask next:
  • What does raise ... from ... do, and why is it useful?
  • Why is a bare except: worse than except Exception:?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

22. How does the with statement work, and how would you write your own context manager?

What the interviewer is really testing:
Whether you know the protocol under with and can use it to make cleanup impossible to forget.
Answer frame:

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.

Sample spoken answer:

"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."

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

Putting cleanup after the yield without try and finally, so it silently never runs when the block raises.

They may ask next:
  • Why must the yield be inside try and finally in a generator-based context manager?
  • What is contextlib.ExitStack for?
Say it in 60 seconds
Easy Situational round Fresher, Mid-level, Senior Practice question

23. You're reviewing a pull request that wraps a whole function in except Exception: pass. What do you do?

What the interviewer is really testing:
Whether you understand why silently swallowing errors is dangerous and can give review feedback that is firm but helpful.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Approving it because the code doesn't crash, or rejecting it with no explanation of the risk.

They may ask next:
  • Why is a bare except: even worse than except Exception:?
  • When is it right to catch a broad exception at all?
Say it in 60 seconds

OOP 3 questions

Medium Technical round Fresher, Mid-level Practice question

24. What are dunder methods? Show how you would make a class print clearly and compare by value.

What the interviewer is really testing:
Whether you know how Python's built-in behaviour hooks into your classes, and the link between __eq__ and __hash__.
Answer frame:

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.

Sample spoken answer:

"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."

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

Writing __eq__ and then being surprised that the objects can't go in a set, or confusing __repr__ with __str__.

They may ask next:
  • Why must two objects that are equal also have the same hash?
  • What is the difference between returning NotImplemented and raising NotImplementedError?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

25. In multiple inheritance, how does Python decide which method runs, and what does super() really call?

What the interviewer is really testing:
Whether you know the method resolution order and that super() means the next class in that order, not simply the parent.
Answer frame:

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.

Sample spoken answer:

"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."

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

Saying super() always calls the parent class, or that Python searches depth-first and visits Base twice.

They may ask next:
  • Why do cooperative classes usually accept **kwargs in __init__ and pass them on to super()?
  • What error do you get if Python can't build a consistent MRO?
Say it in 60 seconds
Easy Technical round Fresher Practice question

26. What is the difference between an instance method, a @classmethod and a @staticmethod? When do you use each?

What the interviewer is really testing:
Whether you know what each one receives and can name a real use, especially alternative constructors.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Not being able to give a real use for classmethod, or saying staticmethod receives the class.

They may ask next:
  • Why is return cls(...) better than return Date(...) in an alternative constructor?
  • Can you call a classmethod on an instance, and what does it receive?
Say it in 60 seconds

Real Work 4 questions

Medium Behavioral round Fresher, Mid-level Practice question

27. Tell me about a Python script or service you made much faster. How did you find where the time went?

What the interviewer is really testing:
Whether you measure before optimising, and whether your fix came from understanding the cause rather than trying things.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

A speed-up story with no measurement, or rewriting everything in another language before finding the hot spot.

They may ask next:
  • What would you use to profile a long-running service without restarting it?
  • When is it not worth optimising Python code that looks slow?
Say it in 60 seconds
Hard Behavioral round Mid-level, Senior Practice question

28. Tell me about a time a Python process kept using more and more memory. How did you find the cause?

What the interviewer is really testing:
Whether you have debugged a real memory problem methodically, with tools and snapshots, and fixed the reference that kept objects alive.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Fixing it by scheduling restarts or calling gc.collect() in a loop, without ever finding what held the objects.

They may ask next:
  • How would you tell a true leak apart from memory the process simply hasn't returned to the operating system?
  • What would you check if the growth were inside a C extension rather than Python objects?
Say it in 60 seconds
Medium Behavioral round Fresher, Mid-level, Senior Practice question

29. Tell me about a bug you shipped that Python's flexibility let through. What did you change afterwards?

What the interviewer is really testing:
Whether you own mistakes and respond with a lasting safeguard, such as tests, type checking or linting, rather than just a patch.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Blaming the language or a teammate, or a story where nothing changed after the fix.

They may ask next:
  • How do you convince a team to adopt type checking on an existing codebase?
  • What kinds of bugs will type checking not catch?
Say it in 60 seconds
Medium Situational round Fresher, Mid-level Practice question

30. A Python script works on your laptop but fails on the server with an import error. How do you sort it out and stop it recurring?

What the interviewer is really testing:
Whether you understand interpreters, virtual environments and dependency pinning well enough to make builds repeatable.
Answer frame:

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.

Sample spoken answer:

"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."

Red flag to avoid:

Installing packages globally with admin rights until it works, with no pinning and no idea why it broke.

They may ask next:
  • Why is python -m pip install safer than a bare pip install?
  • How do you keep pinned dependencies up to date without breaking things?
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

The questions above are the prep. The call has ten more.

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.

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