Skip to main content
PProfileMatch

Python Interview Questions and Answers

26 questions8 beginner · 10 intermediate · 8 advancedLast reviewed

This set is for anyone interviewing for a Python developer, backend, data or automation role, from a first job to a senior screen. The questions are the recurring ones: how the language really behaves, where it surprises people, and how you reason about concurrency and memory.

Practise by answering each question out loud in under a minute before you open the model answer, then compare. Run the code blocks yourself and change one line to predict what happens. If a topic feels shaky, the Python fundamentals course covers it step by step.

  1. 1What is the difference between mutable and immutable objects in Python? Give examples of each.Beginner

    An immutable object cannot change after it is created; a mutable one can be modified in place. Strings, ints, floats, bools, tuples and frozensets are immutable. Lists, dicts, sets and most user-defined class instances are mutable.

    The practical consequence is aliasing: if two names point to the same list, a change through one name is visible through the other. With a string, operations like concatenation create a new object instead. A subtle case is a tuple holding a list: the tuple's slots cannot be reassigned, but the list inside can still change.

    python
    cart = ("tea", ["milk"])
    cart[1].append("sugar")   # fine: mutates the list inside
    print(cart)
    try:
        cart[0] += "x"        # tries to rebind a tuple slot
    except TypeError as e:
        print("err", e)

    Output

    ('tea', ['milk', 'sugar'])
    err 'tuple' object does not support item assignment

    What the interviewer is checking: Whether you know immutability is about the object itself and can spot the tuple-containing-a-list edge case.

    Likely follow-up: Why does that matter for using something as a dictionary key?

  2. 2When would you pick a list, a tuple, a set or a dict?Beginner

    Pick by what you need to do with the data. A list is an ordered, growable sequence, good for collections you append to and iterate over. A tuple is a fixed-size record, such as a (latitude, longitude) pair, and it is hashable if its contents are. A set stores unique items with fast membership tests and set algebra. A dict maps keys to values with fast lookup, and keeps insertion order.

    A common rule: if you keep asking whether x is in a list inside a loop, you probably want a set or a dict, because membership is average O(1) there versus O(n) for a list.

    What the interviewer is checking: Choosing the structure from access patterns and complexity rather than reciting definitions.

    Likely follow-up: What does a set require of its elements, and why?

  3. 3What is the difference between is and == in Python?Beginner

    == asks whether two objects have equal values, by calling __eq__. is asks whether two names refer to the very same object in memory, which is an identity check.

    Use == for comparing data and is for singletons, almost always None: if result is None. Never rely on is for numbers or strings. CPython may reuse small ints or interned strings, which makes is appear to work in a quick test and then fail with other values or on another interpreter.

    What the interviewer is checking: That you separate value equality from identity and know the one place is is the right tool.

    Likely follow-up: Why can a custom __eq__ make == return something surprising, and is cannot be overridden?

  4. 4Explain *args and **kwargs. How do keyword-only arguments fit in?Beginner

    *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. They let a function accept a flexible call signature, and are common in wrappers that forward everything to another function.

    Any parameter after *args (or a bare *) is keyword-only, so callers must name it. That is a good API habit for flags like dry_run=True, since it prevents unreadable calls such as ship(order, True, False). The same stars also work at call sites to unpack a sequence or mapping.

    What the interviewer is checking: Practical command of the argument rules, including the keyword-only part many candidates skip.

    Likely follow-up: What error do you get if you omit the required keyword-only argument?

  5. 5What is a list comprehension, and when should you avoid one?Beginner

    A list comprehension builds a list from an iterable in one expression, optionally filtering: [n for n in readings if n > 0]. It is usually clearer and faster than the equivalent loop with append.

    Avoid it when the logic needs several statements, nested conditions that hurt readability, or side effects such as printing or writing to a file. If you only need to loop over results once, a generator expression avoids building the whole list in memory. In Python 3 the loop variable does not leak out of the comprehension.

    What the interviewer is checking: Taste: knowing the tool and also when readability or memory argues against it.

    Likely follow-up: What is the difference between a list comprehension and a generator expression?

  6. 6Why can a dictionary key be a tuple but not a list?Beginner

    Dict keys must be hashable: they need a hash value that never changes during their lifetime, and equality that agrees with the hash. A list is mutable, so its contents and therefore its hash could change after insertion, and the dict would never find the entry again.

    A tuple is hashable only if everything inside it is hashable, so a tuple of ints works as a key but a tuple containing a list does not. Common uses are coordinates or composite keys like (user_id, date). If you need a list-like key, convert it with tuple(...) first.

    What the interviewer is checking: Whether you connect hashability to mutability and to how hash tables work.

    Likely follow-up: What happens to hashability if a class defines __eq__ and nothing else?

  7. 7What are dunder methods? Explain __repr__ versus __str__.Beginner

    Dunder (double underscore) methods are hooks that let your class plug into Python's syntax and built-ins: __init__ for construction, __len__ for len(), __add__ for +, __iter__ for loops, __enter__ and __exit__ for with.

    __repr__ is for developers: an unambiguous string, ideally one that could rebuild the object, and it is what you see in the REPL, in logs and inside containers. __str__ is for end users and is used by print and str(). If you define only one, define __repr__, because str() falls back to it.

    What the interviewer is checking: Awareness of the data model and the repr-for-debugging convention.

    Likely follow-up: Which dunder methods would you add so your class supports sorting?

  8. 8Why use virtual environments, and how do you set one up for a project?Beginner

    A virtual environment is an isolated Python install with its own site-packages. Without one, every project shares the interpreter's global packages, so two apps needing different versions of the same library collide, and installs can break system tools.

    Create one with python -m venv .venv, activate it, then pip install -r requirements.txt. Keep dependencies pinned in a requirements or lock file, and keep .venv out of version control so teammates and CI rebuild the same environment. Tools such as poetry, uv or pipenv wrap the same idea and add dependency resolution and lock files.

    What the interviewer is checking: Basic engineering hygiene: reproducible, isolated dependencies.

    Likely follow-up: How do you make sure production installs exactly what you tested?

  9. 9What is wrong with this function, and how do you fix it? def add_stop(stop, route=[])Intermediate

    The default list is created once, when the function is defined, not on each call. Every call that omits route shares the same list, so state leaks between calls and the bug shows up far from its cause.

    The standard fix is to use None as the default and create a fresh list inside the function. The same trap applies to any mutable default: dicts, sets and objects. Use is None rather than a truthiness check, since an empty list passed deliberately by the caller is falsy and you would wrongly replace it.

    python
    def add_stop(stop, route=[]):
        route.append(stop)
        return route
    
    print(add_stop("A"))
    print(add_stop("B"))
    
    def add_stop2(stop, route=None):
        if route is None:
            route = []
        route.append(stop)
        return route
    
    print(add_stop2("A"))
    print(add_stop2("B"))

    Output

    ['A']
    ['A', 'B']
    ['A']
    ['B']

    What the interviewer is checking: Knowing when defaults are evaluated, and fixing it with the correct sentinel check.

    Likely follow-up: Is there a case where this behaviour is deliberately useful?

  10. 10Explain shallow copy versus deep copy. When does the difference bite?Intermediate

    A shallow copy creates a new outer container but reuses the same inner objects. A deep copy recursively copies everything, so the result shares nothing mutable with the original. copy.copy, list.copy(), dict.copy(), slicing and dict(d) are all shallow; copy.deepcopy is deep.

    It bites with nested data: change an inner list through the shallow copy and the original changes too. The classic version is [[0]*3]*2, which makes two references to one row. Deep copies cost time and memory and can be unnecessary; often copying only the layer you mutate is enough.

    python
    import copy
    order = {"id": 7, "items": [{"sku": "A1", "qty": 1}]}
    shallow = order.copy()
    deep = copy.deepcopy(order)
    order["items"][0]["qty"] = 5
    print(shallow["items"][0]["qty"], deep["items"][0]["qty"])
    
    grid = [[0]*3]*2
    grid[0][0] = 1
    print(grid)
    grid2 = [[0]*3 for _ in range(2)]
    grid2[0][0] = 1
    print(grid2)

    Output

    5 1
    [[1, 0, 0], [1, 0, 0]]
    [[1, 0, 0], [0, 0, 0]]

    What the interviewer is checking: Whether you understand shared references and can recognise the aliasing bug in real code.

    Likely follow-up: How does deepcopy handle objects that refer to themselves?

  11. 11What is the difference between an iterator and a generator, and why would you use a generator?Intermediate

    An iterator is any object with __iter__ and __next__ that produces values one at a time and raises StopIteration when done. A generator is a convenient way to build one: a function containing yield returns a generator object that pauses at each yield and resumes where it left off.

    Generators are lazy, so you can process a huge file or an endless stream in constant memory, and you can chain them as a pipeline. The catch is that they are single-use: once exhausted they yield nothing, which silently breaks code that loops over the same generator twice.

    python
    def read_batches(lines, size):
        batch = []
        for line in lines:
            batch.append(line)
            if len(batch) == size:
                yield batch
                batch = []
        if batch:
            yield batch
    
    gen = read_batches((f"row{i}" for i in range(5)), 2)
    print(next(gen))
    print(list(gen))
    print(list(gen))

    Output

    ['row0', 'row1']
    [['row2', 'row3'], ['row4']]
    []

    What the interviewer is checking: Understanding laziness, memory trade-offs and the single-use pitfall.

    Likely follow-up: What does yield from add over a plain loop that yields each item?

  12. 12How does a decorator work? Write one that retries a flaky call.Intermediate

    A decorator is a callable that takes a function and returns a replacement, usually a wrapper that adds behaviour before or after the original. @retry(3) above a function is shorthand for fn = retry(3)(fn). With arguments you need three layers: the factory, the decorator and the wrapper.

    Always apply functools.wraps to the wrapper so the decorated function keeps its name and docstring, which matters for debugging, logging and tools that inspect functions. Catch only the exceptions you expect to be transient, and re-raise on the final attempt rather than swallowing the error.

    python
    import functools
    
    def retry(times):
        def deco(fn):
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                for attempt in range(1, times + 1):
                    try:
                        return fn(*args, **kwargs)
                    except ConnectionError:
                        if attempt == times:
                            raise
            return wrapper
        return deco
    
    calls = {"n": 0}
    
    @retry(3)
    def fetch_rate():
        """Fetch the rate."""
        calls["n"] += 1
        if calls["n"] < 3:
            raise ConnectionError
        return 1.08
    
    print(fetch_rate(), calls["n"], fetch_rate.__name__, fetch_rate.__doc__)

    Output

    1.08 3 fetch_rate Fetch the rate.

    What the interviewer is checking: Closures, higher-order functions and the habit of preserving metadata with wraps.

    Likely follow-up: How would you add exponential backoff, and why add jitter?

  13. 13What is a context manager? How would you write your own?Intermediate

    A context manager guarantees setup and cleanup around a block via the with statement. It implements __enter__ and __exit__; __exit__ runs even if the block raises, which is why files, locks and database transactions use it. Returning a truthy value from __exit__ suppresses the exception, which you should rarely do.

    The shortest way to write one is contextlib.contextmanager on a generator: code before yield is setup, code after it is cleanup. Wrap the yield in try/finally, otherwise an exception inside the block skips your cleanup.

    python
    from contextlib import contextmanager
    
    @contextmanager
    def locker(name):
        print("open", name)
        try:
            yield name
        finally:
            print("close", name)
    
    try:
        with locker("L7") as l:
            print("using", l)
            raise ValueError("jammed")
    except ValueError as e:
        print("caught", e)

    Output

    open L7
    using L7
    close L7
    caught jammed

    What the interviewer is checking: Resource-safety thinking and knowing the try/finally detail inside generator-based managers.

    Likely follow-up: What does with open(a) as f, open(b) as g guarantee if the second open fails?

  14. 14Why does this print [2, 2, 2], and how do you fix it? [lambda: i for i in range(3)]Intermediate

    Closures capture variables, not values. All three lambdas refer to the same i from the enclosing scope, and by the time you call them the loop has finished with i == 2. This is called late binding.

    Fix it by binding the current value at definition time with a default argument, lambda i=i: i, or by using functools.partial, or a small factory function that takes the value as a parameter. The problem is not specific to lambdas: any function defined in a loop and called later has it, for example callbacks registered for a list of buttons or tasks.

    python
    def make():
        return [lambda: i for i in range(3)]
    print([f() for f in make()])
    
    def make2():
        return [lambda i=i: i for i in range(3)]
    print([f() for f in make2()])

    Output

    [2, 2, 2]
    [0, 1, 2]

    What the interviewer is checking: A real understanding of closure scoping rather than a memorised trick.

    Likely follow-up: Does the same thing happen in JavaScript with let in a for loop?

  15. 15Explain Python's LEGB rule. What do global and nonlocal do, and what is UnboundLocalError?Intermediate

    Name lookup goes Local, Enclosing function, Global (module), then Built-in. Reading a name searches outward; assigning to a name makes it local to the current function unless you declare it global or nonlocal. nonlocal rebinds a variable in the nearest enclosing function, global rebinds a module-level one.

    UnboundLocalError appears when a function assigns to a name anywhere in its body, which makes that name local for the whole function, and then reads it before the assignment runs. The fix is to rename, pass the value in, or declare it global. Mutating an object, like items.append(x), does not need a declaration because it is not rebinding.

    python
    rate = 10
    
    def outer():
        rate = 20
        def inner():
            nonlocal rate
            rate += 1
            return rate
        return inner()
    
    print(outer(), rate)
    
    def bad():
        print(rate)
        rate = 5
    
    try:
        bad()
    except UnboundLocalError as e:
        print(type(e).__name__, e)

    Output

    21 10
    UnboundLocalError cannot access local variable 'rate' where it is not associated with a value

    What the interviewer is checking: Scoping fundamentals, including the compile-time decision that a name is local.

    Likely follow-up: Why is relying on global state usually a design smell?

  16. 16What does @dataclass give you, and what is the catch with mutable defaults?Intermediate

    @dataclass generates boilerplate from annotated fields: __init__, __repr__ and __eq__, and optionally ordering methods, plus frozen=True for immutability. That makes it ideal for plain data carriers, such as a fare quote or an API payload, with less code and fewer typos.

    For mutable defaults like a list, you must use field(default_factory=list); writing = [] raises a ValueError precisely to avoid the shared-default bug. Note that a frozen dataclass with eq=True is hashable, so it can be a dict key or set member, but only if its fields are themselves hashable.

    python
    from dataclasses import dataclass, field
    
    @dataclass(frozen=True)
    class Fare:
        km: float
        surge: float = 1.0
        def total(self):
            return round(self.km * 12 * self.surge, 2)
    
    f = Fare(3.5, 1.5)
    print(f, f.total(), f == Fare(3.5, 1.5))
    try:
        f.km = 2
    except Exception as e:
        print(type(e).__name__)
    
    @dataclass
    class Good:
        items: list = field(default_factory=list)
    print(Good(), Good().items is Good().items)

    Output

    Fare(km=3.5, surge=1.5) 63.0 True
    FrozenInstanceError
    Good(items=[]) False

    What the interviewer is checking: Knowing when to reach for dataclasses and how they avoid the mutable-default trap.

    Likely follow-up: When would you choose a dataclass over a NamedTuple or a plain dict?

  17. 17If you define __eq__ on a class, what happens to __hash__, and why does it matter?Intermediate

    In Python 3, defining __eq__ without __hash__ sets __hash__ to None, making instances unhashable. That is intentional: objects that compare equal must have equal hashes, and the default identity-based hash would violate that.

    If instances should be usable in sets or as dict keys, define __hash__ from the same fields that __eq__ compares, and make those fields effectively immutable. Hashing on a field that later changes corrupts the container, because the object sits in the wrong bucket and lookups fail. If instances are mutable, leaving them unhashable is the safer choice.

    What the interviewer is checking: Understanding the equality/hash contract and the risk of hashing mutable state.

    Likely follow-up: How does @dataclass(eq=True) decide whether to generate __hash__?

  18. 18Why is removing items from a list while looping over it a bug, and what should you do instead?Intermediate

    The list iterator walks by index. When you remove an element, everything after it shifts left, so the next element is skipped. The loop does not raise an error, it silently produces wrong results, which makes it a nasty bug.

    Build a new list instead: a comprehension with the condition you want to keep, or filter. If you must mutate in place, iterate over a copy (for x in items[:]) or assign back with items[:] = [...]. Dicts and sets behave differently: changing their size during iteration raises a RuntimeError, so loop over list(d) when you need to delete keys.

    What the interviewer is checking: Whether you can explain the mechanism and not just state the rule.

    Likely follow-up: What happens if you add a key to a dict inside a loop over that dict?

  19. 19What is the GIL, and what does it mean for multithreaded Python code?Advanced

    In standard CPython, the Global Interpreter Lock lets only one thread execute Python bytecode at a time. It exists because CPython's memory management, especially reference counting, is not thread-safe without it, and it makes the interpreter and many C extensions simpler and fast for single-threaded code.

    Consequences: threads do not speed up CPU-bound pure-Python work, but they work well for I/O-bound work because the lock is released while waiting on sockets, files or sleeps. The GIL does not make your code thread-safe either: count += 1 is several bytecodes, so you still need locks or queues around shared state. Some C extensions, such as NumPy, release the GIL during heavy computation.

    What the interviewer is checking: Accurate nuance: what the GIL protects, what it does not, and where threads still help.

    Likely follow-up: Does the GIL mean you never need a threading.Lock?

  20. 20You need to speed up a Python service. How do you choose between threading, multiprocessing and asyncio?Advanced

    Start by identifying the bottleneck. For CPU-bound work, such as image resizing or number crunching in pure Python, use multiprocessing or ProcessPoolExecutor: separate processes have separate interpreters and GILs, at the cost of startup time and serialising data between them.

    For I/O-bound work with a modest number of tasks, threads are the simplest, especially with blocking libraries. For thousands of concurrent connections, asyncio has the lowest per-task overhead, but every library in the path must be async-aware, because one blocking call stalls the whole event loop. Measure first; sometimes a better algorithm, caching or a vectorised library beats any concurrency model.

    What the interviewer is checking: Judgement: matching the concurrency model to the workload and naming the costs.

    Likely follow-up: How would you call a blocking library from inside an asyncio program?

  21. 21How does asyncio run concurrent work on a single thread? What does await actually do?Advanced

    asyncio runs an event loop that schedules coroutines. A coroutine runs until it hits an await on something not ready, such as a network read or a sleep; it then yields control to the loop, which runs another ready coroutine. Nothing runs in parallel, tasks simply interleave at await points.

    asyncio.gather starts several awaitables together and collects results, so three one-second waits take about one second in total. The big pitfall is a blocking call like time.sleep or a synchronous HTTP request inside a coroutine: it freezes the entire loop. Use the async equivalent or run it with asyncio.to_thread. Also, an un-awaited coroutine never runs.

    python
    import asyncio, time
    
    async def call(name, t):
        await asyncio.sleep(t)
        return name
    
    async def main():
        s = time.perf_counter()
        r = await asyncio.gather(call("a", 0.3), call("b", 0.3), call("c", 0.3))
        print(r, round(time.perf_counter() - s, 1))
    
    asyncio.run(main())

    Output

    ['a', 'b', 'c'] 0.3

    What the interviewer is checking: A correct mental model of cooperative multitasking and its main failure mode.

    Likely follow-up: What is the difference between gather and creating tasks with create_task?

  22. 22How does Python manage memory? What is reference counting, and how are cycles handled?Advanced

    CPython tracks how many references point to each object. When the count drops to zero, the object is freed immediately, which makes cleanup mostly deterministic. Objects live on a private heap managed by the interpreter, with its own allocator for small objects.

    Reference counting alone cannot free cycles, such as two nodes that point to each other, so CPython also has a cyclic garbage collector that periodically finds unreachable groups. Typical leaks are not true leaks but objects kept alive by a global cache, a long-lived list, or a closure. Use weakref for caches or back-references that should not keep objects alive. Other implementations like PyPy use different strategies.

    python
    import gc, weakref
    
    class Node:
        def __init__(self):
            self.other = None
    
    n1, n2 = Node(), Node()
    n1.other, n2.other = n2, n1
    w = weakref.ref(n1)
    del n1, n2
    print(w() is not None)   # cycle keeps both alive
    gc.collect()
    print(w())

    Output

    True
    None

    What the interviewer is checking: Depth beyond 'Python has garbage collection': the two mechanisms and their limits.

    Likely follow-up: When would you disable or tune the garbage collector?

  23. 23What is the iterator protocol, and how does a for loop use it?Advanced

    An iterable is anything with __iter__ that returns an iterator. An iterator has __next__, returns the next value, and raises StopIteration when exhausted; iterators are also iterable, returning themselves from __iter__. A for loop calls iter() once on the object, then calls next() repeatedly and stops quietly on StopIteration.

    This is why lists can be looped over many times, since each loop gets a fresh iterator, while a generator can only be consumed once. It also means you can make your own class loopable by implementing the two methods, or more simply by writing __iter__ as a generator function.

    What the interviewer is checking: Precision about iterable versus iterator and what the language does behind a loop.

    Likely follow-up: How would you make this class re-iterable?

  24. 24How does super() work with multiple inheritance, and what is the MRO?Advanced

    The method resolution order is the linear order in which Python searches classes for an attribute. It is computed with the C3 algorithm, which keeps each parent before its own bases and preserves the order you listed bases in. You can inspect it with Class.__mro__.

    super() does not mean 'my parent'; it means 'the next class after me in the instance's MRO'. In a diamond, that lets each class call super().method() and have every class run exactly once, as long as all of them cooperate by calling super(). If one class in the chain skips the call, the classes after it never run.

    What the interviewer is checking: Whether you know super() follows the instance's MRO rather than the direct parent.

    Likely follow-up: When would you prefer composition over multiple inheritance?

  25. 25What does __slots__ do, and what are its trade-offs?Advanced

    By default every instance stores its attributes in a per-instance __dict__. Declaring __slots__ = ("x", "y") replaces that dict with fixed storage for the named attributes, which reduces memory per instance and blocks accidental creation of new attributes.

    It pays off when you create very large numbers of small objects, such as millions of points or events. The trade-offs: no arbitrary new attributes, no __dict__ or weak references unless you add __weakref__, and slightly trickier inheritance, since each subclass needs its own slots to keep the benefit. Measure before using it; for ordinary code it is premature.

    What the interviewer is checking: Understanding of object layout and the discipline to optimise only with evidence.

    Likely follow-up: How would you measure whether slots actually helped?

  26. 26A long-running Python service keeps growing in memory. How do you find the cause?Advanced

    First confirm it is a real leak: plot resident memory over time and check it keeps climbing across requests rather than plateauing. Then compare snapshots. The standard library's tracemalloc records allocation sites; take a snapshot, run load, take another, and diff them to see which lines allocated the most surviving memory.

    Typical culprits are unbounded caches or dicts keyed by request data, lists that only append, references held by closures or global registries, and cycles involving objects with heavy state. Fixes include a bounded cache (functools.lru_cache(maxsize=...) or a TTL cache), weak references, and clearing per-request state. Also rule out native memory from C extensions, which tracemalloc cannot see.

    What the interviewer is checking: A methodical debugging approach with the right tool, not guesswork.

    Likely follow-up: What is the difference between a leak in Python objects and a leak in a C extension?

Fill the gaps with a free course

If one of these Python answers felt shaky, these course covers the same ground step by step: Python Fundamentals.

More interview questions

Practising Python for a real opening? Upload your resume to see which open jobs list Python skills you already have, or browse open roles.