Editor

Python Interview Questions

60 common questions, each with a short answer you can give in an interview and code you can run.

Beginner questions

Types, copying and the basic built-ins.

  1. 1.What is the difference between a list and a tuple?

    A list is mutable: you can add, remove and replace items after creating it. A tuple is immutable: once it exists, its items cannot be added, removed or reassigned. Because it cannot change, a tuple of hashable items can be a dictionary key or a set member, and a list cannot.

    Use a list for a collection that grows or changes, like a shopping cart, and a tuple for a fixed record, like a pair of coordinates.

    list_vs_tuple.py
    cities = ["Paris", "Rome"]
    cities.append("Oslo")
    print(cities)
    point = (3, 4)
    try:
    point[0] = 10
    except TypeError as error:
    print("TypeError:", error)
    labels = {(0, 0): "origin", point: "target"} # tuples work as keys
    print(labels[(3, 4)])
    Output
    ['Paris', 'Rome', 'Oslo']
    TypeError: 'tuple' object does not support item assignment
    target

    Follow-up: Can a tuple ever change? Its slots cannot be reassigned, but a mutable object inside it, such as a list, can still be modified, and such a tuple can no longer be hashed.

    Go deeper: Python Tuples: When and Why to Use Immutable Sequences

  2. 2.What is the difference between == and is?

    == checks whether two objects have equal values, while is checks whether two names refer to the very same object. Two separate lists with the same items are equal but not identical. Use is only for singletons such as None (if result is None), and == for every comparison of values.

    equality_vs_identity.py
    a = [1, 2, 3]
    b = [1, 2, 3]
    c = a
    print(a == b) # same values
    print(a is b) # two different objects
    print(a is c) # c is just another name for a
    result = None
    print(result is None)
    Output
    True
    False
    True
    True

    Follow-up: Why can is be True for two equal small integers? CPython caches the integers from -5 to 256 and reuses them, an implementation detail you should never rely on.

    Go deeper: 15 Python Pitfalls That Trip Up Even Experienced Developers

  3. 3.What are mutable and immutable types?

    A mutable object can be changed in place after it is created; an immutable object cannot, so every "change" builds a new object. Lists, dicts, sets and most class instances are mutable, while ints, floats, strings, tuples, frozensets and bools are immutable.

    It matters when two names share one object: a change made to a mutable object through one name shows up through the other.

    mutable_vs_immutable.py
    scores = [70, 85]
    backup = scores
    scores.append(90)
    print(backup) # the same list, so it changed too
    name = "sam"
    original = name
    name = name.upper() # builds a new string
    print(original, name)
    word = "hello"
    try:
    word[0] = "J"
    except TypeError as error:
    print("TypeError:", error)
    Output
    [70, 85, 90]
    sam SAM
    TypeError: 'str' object does not support item assignment

    Follow-up: If ints are immutable, how does count += 1 work? It does not modify the int: it creates a new int object and rebinds the name count to it.

    Go deeper: Python Copy vs Deepcopy: Why Your "Copy" Isn't Really a Copy

  4. 4.How do you reverse a string or a list?

    Slicing with a step of -1, [::-1], returns a reversed copy and works on strings, lists and tuples. Lists also have .reverse(), which reverses in place and returns None, and the built-in reversed() returns a lazy iterator, so wrap it in list() or "".join() to get a result. Strings are immutable, so they have no .reverse() method. All of these are O(n).

    reverse_string_or_list.py
    word = "python"
    print(word[::-1])
    print("".join(reversed(word)))
    numbers = [1, 2, 3, 4]
    print(numbers[::-1]) # a new list
    print(numbers) # unchanged
    numbers.reverse() # in place, returns None
    print(numbers)
    Output
    nohtyp
    nohtyp
    [4, 3, 2, 1]
    [1, 2, 3, 4]
    [4, 3, 2, 1]

    Follow-up: How do you check whether a word is a palindrome? Compare it with its reverse, word == word[::-1], after lowercasing it and removing spaces if the input can contain them.

    Go deeper: Python Slicing: Master Indexing for Strings, Lists, and Tuples

  5. 5.What is the difference between append() and extend()?

    append(x) adds x as one single item at the end of the list, even if x is itself a list. extend(iterable) loops over its argument and adds each item separately. So appending a two-item list grows the list by one (a nested list), while extending grows it by two. items += other does the same as extend().

    append_vs_extend.py
    fruits = ["apple", "banana"]
    fruits.append(["cherry", "kiwi"])
    print(fruits, len(fruits))
    fruits = ["apple", "banana"]
    fruits.extend(["cherry", "kiwi"])
    print(fruits, len(fruits))
    letters = ["a"]
    letters.extend("bc") # a string is an iterable of characters
    print(letters)
    Output
    ['apple', 'banana', ['cherry', 'kiwi']] 3
    ['apple', 'banana', 'cherry', 'kiwi'] 4
    ['a', 'b', 'c']

    Follow-up: What does insert() do? items.insert(i, x) puts one item at index i, which is O(n) because every later item has to shift right, while append() is O(1) on average.

    Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists

  6. 6.What are *args and **kwargs?

    *args collects any extra positional arguments into a tuple, and **kwargs collects any extra keyword arguments into a dict. The names are only a convention: the * and ** do the work. They let one function accept any number of arguments, which is why wrappers and decorators use them.

    At a call site the same symbols unpack: f(*values) spreads a list into positional arguments and f(**options) spreads a dict into keyword arguments.

    args_and_kwargs.py
    def describe(*args, **kwargs):
    print("args:", args)
    print("kwargs:", kwargs)
    describe("Asha", 21, city="Pune", course="Python")
    def total(*prices):
    return sum(prices)
    print(total(10, 20, 30))
    values = [5, 15]
    print(total(*values)) # unpack the list into arguments
    Output
    args: ('Asha', 21)
    kwargs: {'city': 'Pune', 'course': 'Python'}
    60
    20

    Follow-up: In what order do parameters go? Normal parameters, then *args, then keyword-only parameters, then **kwargs, as in def f(a, *args, flag=False, **kwargs).

    Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments

  7. 7.What is a list comprehension?

    A list comprehension builds a new list from an iterable in a single expression: [expression for item in iterable if condition]. It replaces the pattern of creating an empty list, looping and calling append(), and it is usually shorter and a little faster. The if part is optional and filters items. If it needs several loops and conditions, a plain for loop is easier to read.

    list_comprehension.py
    prices = [120, 45, 300, 80]
    in_cents = [p * 100 for p in prices]
    cheap = [p for p in prices if p < 100]
    labels = ["high" if p > 100 else "low" for p in prices]
    print(in_cents)
    print(cheap)
    print(labels)
    squares = {n: n * n for n in range(4)} # a dict comprehension
    print(squares)
    Output
    [12000, 4500, 30000, 8000]
    [45, 80]
    ['high', 'low', 'high', 'low']
    {0: 0, 1: 1, 2: 4, 3: 9}

    Follow-up: What changes if you use parentheses instead of square brackets? (p * 2 for p in prices) is a generator expression: it produces items lazily, one at a time, instead of building the whole list in memory.

    Go deeper: Python Comprehensions: List, Dict, Set, and Generator in One Guide

  8. 8.What do break, continue and pass do?

    break exits the nearest enclosing loop at once. continue skips the rest of the current iteration and jumps to the next one. pass does nothing: it is a placeholder for places where Python requires a statement, such as an empty function or class you will fill in later.

    break_continue_pass.py
    for n in [1, 2, 3, 4, 5, 6]:
    if n == 2:
    continue # skip 2, go on with the next number
    if n == 5:
    break # leave the loop completely
    print(n)
    def send_report():
    pass # a body is required, nothing to do yet
    print(send_report())
    Output
    1
    3
    4
    None

    Follow-up: What does else on a loop do? It runs only if the loop finished without hitting break, which makes search loops tidy: the else is the "not found" case.

    Go deeper: Python break, continue, pass: Controlling Loop Flow

  9. 9.How do you remove duplicates from a list and keep the order?

    Use list(dict.fromkeys(items)): dict keys are unique and keep insertion order (guaranteed since Python 3.7), so you get the first occurrence of each item in its original position. list(set(items)) also removes duplicates but scrambles the order. Both are O(n) and need hashable items.

    A loop with a seen set is also O(n) and lets you add rules, such as ignoring case.

    remove_duplicates_keep_order.py
    names = ["Ana", "Ben", "Ana", "Cara", "Ben"]
    print(list(dict.fromkeys(names)))
    seen = set()
    unique = []
    for name in names:
    if name not in seen:
    seen.add(name)
    unique.append(name)
    print(unique)
    print(sorted(set(names))) # unique, but sorted, not in the original order
    Output
    ['Ana', 'Ben', 'Cara']
    ['Ana', 'Ben', 'Cara']
    ['Ana', 'Ben', 'Cara']

    Follow-up: What if the items are unhashable, like lists? Keep an item only if it is not in the result list so far, which works for any items but is O(n^2).

    Go deeper: Python Sets: Unique Collections, Operations, and Use Cases

  10. 10.What is the difference between /, // and %?

    / is true division and always returns a float, even for 10 / 2. // is floor division: it rounds the result down toward negative infinity, so -7 // 2 is -4, not -3. % returns the remainder, which takes the sign of the divisor, so -7 % 2 is 1. The three always agree: a == (a // b) * b + a % b.

    division_operators.py
    print(7 / 2, 10 / 2)
    print(7 // 2, -7 // 2)
    print(7 % 2, -7 % 2)
    minutes = 135
    print(minutes // 60, "h", minutes % 60, "min")
    print(divmod(minutes, 60)) # both at once
    Output
    3.5 5.0
    3 -4
    1 1
    2 h 15 min
    (2, 15)

    Follow-up: How do you test whether a number is even? n % 2 == 0, which also works for negative numbers because % with a positive divisor always returns 0 or 1.

    Go deeper: Python Numbers: Integers, Floats, and Math You'll Actually Use

  11. 11.What does if __name__ == "__main__": do?

    Every module has a __name__ variable. Python sets it to "__main__" when the file is run directly, and to the module name (such as "greet") when the file is imported. So the code under if __name__ == "__main__": runs only when the file is executed as a script, never on import. It is the standard home for a script entry point, demos and quick tests.

    if_name_main.py
    import runpy
    with open("greet.py", "w") as f:
    f.write('print("__name__ is", repr(__name__))\n')
    f.write('if __name__ == "__main__":\n')
    f.write(' print("greet.py was run directly")\n')
    runpy.run_path("greet.py", run_name="__main__") # like: python greet.py
    print("---")
    runpy.run_path("greet.py", run_name="greet") # like: import greet
    Output
    __name__ is '__main__'
    greet.py was run directly
    ---
    __name__ is 'greet'

    Follow-up: What goes wrong without it? Any top-level code, like a demo or a test call, runs every time another file imports the module, printing output or doing work nobody asked for.

    Go deeper: Python Imports: How import Really Works (Modules, Packages, Paths)

  12. 12.What is the difference between a shallow copy and a deep copy?

    A shallow copy creates a new outer container but fills it with references to the same inner objects. A deep copy, copy.deepcopy(), recursively copies everything, so it shares no mutable parts with the original. With nested lists or dicts, changing an inner item through a shallow copy also changes the original.

    list.copy(), list(x), x[:], dict.copy() and copy.copy() all make shallow copies.

    shallow_vs_deep_copy.py
    import copy
    original = [["Ana", 90], ["Ben", 75]]
    shallow = copy.copy(original)
    deep = copy.deepcopy(original)
    original[0][1] = 100 # change an inner list
    print(shallow[0]) # sees the change
    print(deep[0]) # fully independent
    print(shallow is original, shallow[0] is original[0])
    Output
    ['Ana', 100]
    ['Ana', 90]
    False True

    Follow-up: Is b = a a copy? No, assignment copies nothing: both names point to the same object, so a change through either one is seen by both.

    Go deeper: Python Copy vs Deepcopy: Why Your "Copy" Isn't Really a Copy

  13. 13.Which values are falsy in Python?

    A value is falsy when bool(value) is False. The falsy built-ins are None, False, zero of any numeric type (0, 0.0, 0j) and every empty collection: "", [], (), {}, set() and range(0). Everything else is truthy, including "0", " " and [0]. Your own classes can decide by defining __bool__ or __len__.

    falsy_values.py
    falsy = [None, False, 0, 0.0, "", [], (), {}, set(), range(0)]
    print([bool(v) for v in falsy])
    tricky = ["0", " ", [0], -1, "False"]
    print([bool(v) for v in tricky])
    cart = []
    if not cart:
    print("Your cart is empty")
    Output
    [False, False, False, False, False, False, False, False, False, False]
    [True, True, True, True, True]
    Your cart is empty

    Follow-up: Why write if value is None instead of if not value? Because not value is also True for 0 and "", which may be valid data you do not want to treat as missing.

    Go deeper: Python Booleans, Comparisons, and All Operators Explained

  14. 14.How do try, except, else and finally work together?

    The try block holds code that might raise an exception. An except block runs only if a matching exception is raised there. else runs only if the try block raised nothing, which keeps the success path out of the guarded code. finally always runs last, error or not, even after a return, so it is the place for cleanup such as closing files or connections.

    try_except_else_finally.py
    def divide(a, b):
    try:
    result = a / b
    except ZeroDivisionError:
    print("cannot divide by zero")
    return None
    else:
    print("worked:", result)
    return result
    finally:
    print("finished", a, "/", b)
    divide(10, 4)
    divide(1, 0)
    Output
    worked: 2.5
    finished 10 / 4
    cannot divide by zero
    finished 1 / 0

    Follow-up: Why not put the success code inside try too? A larger try can catch errors from lines you never meant to guard and hide real bugs, so else keeps the guarded part small.

    Go deeper: Python Try-Except: Handle Errors Without Crashing

  15. 15.What is the difference between remove(), pop() and del?

    remove(x) deletes the first item equal to x and raises ValueError if there is none. pop(i) deletes the item at index i, the last one by default, and returns it. del items[i] deletes by index or slice and returns nothing; it is a statement, not a method, and can also delete a whole variable. pop() from the end is O(1), while removing from the front or middle is O(n) because later items shift left.

    remove_pop_del.py
    items = ["pen", "book", "pen", "lamp", "cup"]
    items.remove("pen") # the first match only
    print(items)
    last = items.pop() # returns what it removed
    print(last, items)
    del items[0] # by index, returns nothing
    print(items)
    try:
    items.remove("phone")
    except ValueError as error:
    print("ValueError:", error)
    Output
    ['book', 'pen', 'lamp', 'cup']
    cup ['book', 'pen', 'lamp']
    ['pen', 'lamp']
    ValueError: list.remove(x): x not in list

    Follow-up: How do you remove every occurrence of a value? Build a new list, [x for x in items if x != value], instead of calling remove() again and again.

    Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists

Intermediate questions

Functions as values, generators, decorators, scope and the data model.

  1. 16.Why is a mutable default argument dangerous?

    Default values are created once, when the function is defined, not on every call. A default list or dict is therefore shared by every call that does not pass its own, so changes leak from one call into the next.

    Use None as the default and create the list inside the function.

    mutable_default_arguments.py
    def add_item(item, basket=[]):
    basket.append(item)
    return basket
    print(add_item("apple"))
    print(add_item("bread")) # the same list as before
    def add_item_safe(item, basket=None):
    if basket is None:
    basket = []
    basket.append(item)
    return basket
    print(add_item_safe("apple"))
    print(add_item_safe("bread"))
    Output
    ['apple']
    ['apple', 'bread']
    ['apple']
    ['bread']

    Follow-up: Where is the default stored? In the function object, add_item.__defaults__, which is why it survives between calls.

    Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments

  2. 17.What is a lambda, and when should you use one?

    A lambda is a small anonymous function written as a single expression, lambda args: expression, whose value it returns automatically. It cannot contain statements such as assignments, loops or return. Use it for short throwaway functions passed to another function, most often as the key for sorted(), min() or max(). If you would assign it to a name, PEP 8 says to write a def instead, and the same goes for anything longer than one simple expression.

    lambda_functions.py
    students = [("Ana", 88), ("Ben", 72), ("Cara", 95)]
    by_score = sorted(students, key=lambda s: s[1], reverse=True)
    print(by_score)
    print(max(students, key=lambda s: s[1]))
    double = lambda x: x * 2 # works, but a def would be clearer
    print(double(21), double.__name__)
    Output
    [('Cara', 95), ('Ana', 88), ('Ben', 72)]
    ('Cara', 95)
    42 <lambda>

    Follow-up: What is the classic lambda trap in a loop? [lambda: i for i in range(3)] makes three functions that all return 2, because i is looked up when they are called; bind it early with lambda i=i: i.

    Go deeper: Python Lambda Functions: When to Use (and When Not To)

  3. 18.What do map(), filter() and zip() return?

    In Python 3 all three return lazy iterators, not lists. map(f, items) applies f to each item, filter(f, items) keeps the items for which f returns something truthy, and zip(a, b) pairs items up and stops at the shortest input. Nothing is computed until you loop over the result or wrap it in list(), and each one can be consumed only once.

    map_filter_zip.py
    prices = [10, 25, 40]
    names = ["pen", "book", "bag"]
    doubled = map(lambda p: p * 2, prices)
    print(type(doubled).__name__)
    print(list(doubled))
    print(list(doubled)) # already used up
    print(list(filter(lambda p: p > 15, prices)))
    print(list(zip(names, prices)))
    print(dict(zip(names, prices)))
    Output
    map
    [20, 50, 80]
    []
    [25, 40]
    [('pen', 10), ('book', 25), ('bag', 40)]
    {'pen': 10, 'book': 25, 'bag': 40}

    Follow-up: How do you make zip() refuse inputs of different lengths? Pass strict=True (Python 3.10+), which raises ValueError instead of silently dropping the extra items.

    Go deeper: Python map(), filter(), reduce(), and Higher-Order Functions

  4. 19.What is a generator, and how is it different from a list?

    A generator produces its values lazily, one at a time and only when asked, instead of storing them all in memory like a list. That makes it a good fit for large or endless sequences and step-by-step pipelines. The trade-off is that you can loop over a generator only once, and it has no len() and no indexing. You create one with a generator function (one that uses yield) or a generator expression in parentheses.

    generators_vs_lists.py
    squares_list = [n * n for n in range(5)]
    squares_gen = (n * n for n in range(5))
    print(squares_list)
    print(type(squares_gen).__name__)
    print(next(squares_gen), next(squares_gen))
    print(list(squares_gen)) # the rest
    print(list(squares_gen)) # empty: used up
    total = sum(n * n for n in range(1_000_000)) # never builds a big list
    print(total)
    Output
    [0, 1, 4, 9, 16]
    generator
    0 1
    [4, 9, 16]
    []
    333332833333500000

    Follow-up: How do you process a file too big for memory? Loop over the file object itself, which yields one line at a time, or chain generators so each step handles one line before the next is read.

    Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory

  5. 20.What does yield do?

    yield turns a function into a generator function: calling it does not run the body, it returns a generator. Each next() runs the body up to the next yield, hands that value out and pauses, keeping every local variable. The next call resumes right after that yield. When the function ends, the generator raises StopIteration, which a for loop handles for you.

    yield_keyword.py
    def countdown(n):
    print("starting")
    while n > 0:
    yield n
    n -= 1
    print("finished")
    gen = countdown(3)
    print("created, nothing has run yet")
    print(next(gen))
    print(next(gen))
    for value in gen:
    print(value)
    Output
    created, nothing has run yet
    starting
    3
    2
    1
    finished

    Follow-up: What does yield from do? It delegates to another iterable and yields each of its values in turn, so yield from range(3) replaces a small for loop around yield.

    Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory

  6. 21.What is a decorator? Write one that times a function.

    A decorator is a function that takes a function and returns a new one, usually a wrapper that adds behavior before or after the original call. Writing @timer above def total is shorthand for total = timer(total). The wrapper takes *args, **kwargs so it fits any signature, and functools.wraps copies the name and docstring of the original onto it. Decorators are used for logging, caching, timing, retries and access checks.

    timing_decorator.py
    import functools, time
    def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
    start = time.perf_counter()
    result = func(*args, **kwargs)
    print(f"{func.__name__} took {time.perf_counter() - start:.2f} s")
    return result
    return wrapper
    @timer
    def total(n):
    return sum(range(n))
    print(total(1000), total.__name__) # the name survives, thanks to wraps
    Output
    total took 0.00 s
    499500 total

    Follow-up: How do you write a decorator that takes arguments, like @retry(3)? Add one more outer layer: retry(3) runs first and returns the actual decorator, which then wraps the function.

    Go deeper: Python Decorators Explained: A Step-by-Step Guide

  7. 22.What is a closure?

    A closure is an inner function that remembers variables from the function that created it, even after that outer function has returned. Python keeps those variables alive in cells attached to the inner function (__closure__). Closures give you small configurable or stateful functions without writing a class, and they are how decorators hold on to the function they wrap.

    closures.py
    def make_multiplier(factor):
    def multiply(x):
    return x * factor # factor is remembered from the outer call
    return multiply
    double = make_multiplier(2)
    triple = make_multiplier(3)
    print(double(10), triple(10))
    print(double.__closure__[0].cell_contents) # where factor lives
    Output
    20 30
    2

    Follow-up: When would you pick a class over a closure? When the state needs several methods or should be visible from outside; a closure suits one function with a little private state.

    Go deeper: Python Variable Scope, Closures, and the LEGB Rule

  8. 23.What is the LEGB rule, and what do global and nonlocal do?

    LEGB is the order Python searches for a name: Local (the current function), Enclosing (any outer functions), Global (the module), then Built-in (names like len and print); the first match wins. Assigning to a name inside a function makes it local to that function. To assign to a module-level name instead, declare it global; to assign to a variable of an enclosing function, declare it nonlocal.

    Reading an outer name needs neither keyword: only assignment does.

    legb_global_nonlocal.py
    total = 0 # global: lives in the module
    def make_counter():
    clicks = 0 # enclosing: lives in make_counter
    def click():
    global total # assign to the module's total
    nonlocal clicks # assign to make_counter's clicks
    clicks += 1
    total += 1
    return clicks
    return click
    a, b = make_counter(), make_counter()
    print(a(), a(), b(), "total:", total)
    Output
    1 2 1 total: 3

    Follow-up: What happens if you assign to a global without declaring it? The name becomes local for the whole function, so reading it before the assignment, as in count += 1, raises UnboundLocalError.

    Go deeper: Python Variable Scope, Closures, and the LEGB Rule

  9. 24.What is the difference between an iterable and an iterator?

    An iterable is anything you can loop over, such as a list, string, dict or file: its __iter__() method hands out an iterator. An iterator is the object that does the stepping: __next__() returns the next item and raises StopIteration at the end, and its own __iter__() returns itself. A list can be looped over again and again because every loop gets a fresh iterator, while an iterator is used up after one pass.

    A for loop calls iter() once, then next() until StopIteration.

    iterable_vs_iterator.py
    colors = ["red", "green"] # an iterable
    it = iter(colors) # an iterator over it
    print(next(it))
    print(next(it))
    try:
    next(it)
    except StopIteration:
    print("StopIteration: no more items")
    print(iter(it) is it) # an iterator is its own iterator
    print(list(it), colors) # the iterator is spent, the list is not
    Output
    red
    green
    StopIteration: no more items
    True
    [] ['red', 'green']

    Follow-up: Is every iterator an iterable? Yes, since its __iter__() returns itself, but not every iterable is an iterator: calling next() on a list raises TypeError.

    Go deeper: Python Iterators: How for Loops Really Work Under the Hood

  10. 25.What is the difference between @staticmethod and @classmethod?

    A @classmethod receives the class as its first argument, cls, so it can read class attributes and create instances; its classic use is an alternative constructor such as from_string(). A @staticmethod receives nothing automatically: it is a plain function that lives in the class because it belongs there logically. A regular method receives the instance, self. Both can be called on the class or on an instance.

    staticmethod_vs_classmethod.py
    class Pizza:
    def __init__(self, toppings):
    self.toppings = toppings
    @classmethod
    def margherita(cls): # receives the class
    return cls(["tomato", "cheese"])
    @staticmethod
    def slice_price(total, slices): # receives nothing extra
    return round(total / slices, 2)
    pizza = Pizza.margherita()
    print(type(pizza).__name__, pizza.toppings)
    print(Pizza.slice_price(12, 8))
    Output
    Pizza ['tomato', 'cheese']
    1.5

    Follow-up: Why call cls(...) rather than the class name inside a classmethod? So that a subclass calling the same method gets an instance of the subclass, not of the parent.

    Go deeper: Python @classmethod vs @staticmethod: When to Use Each

  11. 26.What are dunder (magic) methods?

    Dunder methods, named with double underscores like __init__, __repr__, __eq__, __len__ and __add__, are hooks that Python calls for you behind built-in syntax and functions. len(x) calls x.__len__(), a + b calls a.__add__(b), and printing uses __str__, falling back to __repr__. Defining them lets your own classes work with operators, len(), loops and comparisons just like built-in types. You rarely call them directly.

    dunder_methods.py
    class Money:
    def __init__(self, amount):
    self.amount = amount
    def __repr__(self):
    return f"Money({self.amount})"
    def __add__(self, other):
    return Money(self.amount + other.amount)
    def __eq__(self, other):
    return self.amount == other.amount
    print(Money(5) + Money(7))
    print(Money(3) == Money(3), [Money(1), Money(2)])
    Output
    Money(12)
    True [Money(1), Money(2)]

    Follow-up: What happens to hashing when you define __eq__? Python sets __hash__ to None, so instances can no longer be dict keys or set members until you also define __hash__.

    Go deeper: Python Magic Methods: The Complete __dunder__ Guide

  12. 27.Why must dictionary keys be hashable?

    A dict finds a key by computing its hash, jumping straight to the matching slot and confirming with ==; that is what makes lookups O(1) on average. This only works if the hash of a key never changes while it is stored and equal keys have equal hashes. Immutable built-ins such as strings, numbers and tuples of hashable items qualify. Lists, dicts and sets do not: changing one would change its hash and the dict could no longer find the entry.

    hashable_dict_keys.py
    places = {"home": "Pune", (18, 73): "grid cell"}
    print(places[(18, 73)])
    try:
    places[["a", "b"]] = "list key"
    except TypeError as error:
    print("TypeError:", error)
    print(hash((1, 2)) == hash((1, 2))) # equal tuples, equal hashes
    print(hash(1) == hash(1.0), {1: "one"}[1.0])
    Output
    grid cell
    TypeError: unhashable type: 'list'
    True
    True one

    Follow-up: How do you use a list as a key? Convert it to a tuple first, counts[tuple(items)], or to a frozenset if the order does not matter.

    Go deeper: Python Dictionaries: The Complete Guide with Real Examples

  13. 28.What is the difference between sort() and sorted()?

    list.sort() sorts the list in place and returns None, and it exists only on lists. sorted() takes any iterable (a tuple, a string, dict keys, a generator) and returns a new list, leaving the original untouched. Both accept key= and reverse=, use the same stable algorithm and run in O(n log n). A classic bug is marks = marks.sort(), which replaces the list with None.

    sort_vs_sorted.py
    marks = [72, 95, 60]
    print(sorted(marks), marks) # a new list, the original is unchanged
    result = marks.sort() # in place
    print(result, marks)
    print(sorted("banana"))
    words = ["Kiwi", "apple", "Banana"]
    print(sorted(words))
    print(sorted(words, key=str.lower))
    Output
    [60, 72, 95] [72, 95, 60]
    None [60, 72, 95]
    ['a', 'a', 'a', 'b', 'n', 'n']
    ['Banana', 'Kiwi', 'apple']
    ['apple', 'Banana', 'Kiwi']

    Follow-up: What does stable mean? Items that compare equal keep their original order, so you can sort by a secondary key first and then by the main key.

    Go deeper: Python Sorting: sort(), sorted(), Key Functions, and Custom Order

  14. 29.What does enumerate() do, and why use it?

    enumerate(iterable) yields (index, item) pairs, so a loop gets the position and the value together without a manual counter or range(len(items)). The start= argument sets the first number, handy for numbering from 1. It works on any iterable, not just lists, and it is lazy like zip().

    enumerate_function.py
    tasks = ["write", "test", "ship"]
    for number, task in enumerate(tasks, start=1):
    print(f"{number}. {task}")
    print(list(enumerate("abc")))
    scores = [40, 75, 90, 55]
    passed_at = [i for i, s in enumerate(scores) if s >= 60]
    print(passed_at) # positions of the passing scores
    Output
    1. write
    2. test
    3. ship
    [(0, 'a'), (1, 'b'), (2, 'c')]
    [1, 2]

    Follow-up: How would you write enumerate() yourself? As a generator: keep a counter n = start, and for each item yield n, item, then add 1 to n.

    Go deeper: Python For Loops: Iterate Over Anything with Examples

  15. 30.How do you merge two dictionaries?

    Since Python 3.9, a | b returns a new merged dict and a |= b updates a in place. {**a, **b} also builds a new dict, and a.update(b) changes a in place. In every form, when a key is in both, the value from the right-hand dict wins. All of them are shallow: a nested dict on the right replaces the one on the left instead of being merged into it.

    merge_dictionaries.py
    defaults = {"theme": "dark", "font": 14, "lang": "en"}
    user = {"font": 16, "lang": "fr"}
    print(defaults | user) # Python 3.9+
    print({**defaults, **user})
    settings = defaults.copy()
    settings.update(user) # in place
    print(settings)
    print(defaults) # none of the three changed it
    Output
    {'theme': 'dark', 'font': 16, 'lang': 'fr'}
    {'theme': 'dark', 'font': 16, 'lang': 'fr'}
    {'theme': 'dark', 'font': 16, 'lang': 'fr'}
    {'theme': 'dark', 'font': 14, 'lang': 'en'}

    Follow-up: How do you merge nested dicts? Write a small recursive function that merges the values that are both dicts and overwrites the rest, since | and update() only merge the top level.

    Go deeper: Python Dictionaries: The Complete Guide with Real Examples

Advanced questions

Memory, the GIL, the MRO, metaclasses and descriptors.

  1. 31.What is the GIL, and how does it affect threads?

    The GIL (global interpreter lock) is a lock in CPython that lets only one thread run Python bytecode at a time. So threads do not speed up CPU-bound work: two threads crunching numbers take turns on one core. They still help with I/O-bound work, because a thread waiting on the network or the disk releases the GIL and another thread runs meanwhile.

    The GIL does not make your code thread-safe: count += 1 is several steps, a thread can be switched out between them, so shared data still needs a threading.Lock.

    Follow-up: Is the GIL going away? Since Python 3.13 there is an optional free-threaded build without it (PEP 703), but the standard build most people install still has the GIL.

    Go deeper: Python Threading vs Multiprocessing vs asyncio: When to Use What

  2. 32.When would you use threads, processes or asyncio?

    Threads suit I/O-bound work with a modest number of tasks, such as fetching 20 web pages with a blocking library: they are easy to add, and the GIL is released while each one waits. Processes suit CPU-bound work, because each process has its own interpreter and GIL and can keep a core busy; the cost is slower start-up and arguments that must be pickled to cross over. asyncio suits I/O-bound work at high concurrency, like thousands of open sockets, where one thread switches between cheap coroutines at every await.

    The catch with asyncio is that the whole call chain must be async: one blocking call, such as time.sleep or requests.get, stalls every task on the event loop.

    Follow-up: How do you switch between threads and processes easily? concurrent.futures gives ThreadPoolExecutor and ProcessPoolExecutor the same submit and map interface, so it is a one-word change.

    Go deeper: Python Threading vs Multiprocessing vs asyncio: When to Use What

  3. 33.What is a context manager, and how do you write one with contextlib?

    A context manager is an object used with with that runs setup code on entry and cleanup code on exit, even when the block raises an exception: closing files, releasing locks, rolling back transactions. contextlib.contextmanager turns a generator into one: the code before yield is the setup, the yielded value is what as binds, and the code after it is the cleanup. Put the yield inside try/finally, or the cleanup is skipped when the block fails.

    context_managers.py
    from contextlib import contextmanager, suppress
    @contextmanager
    def tag(name):
    print(f"<{name}>")
    try:
    yield name
    finally:
    print(f"</{name}>") # runs even when the block raises
    with suppress(ZeroDivisionError): # contextlib again: swallows the error
    with tag("div") as t:
    print("inside", t)
    1 / 0
    print("carried on after the error")
    Output
    <div>
    inside div
    </div>
    carried on after the error

    Follow-up: How do you write one as a class? Define __enter__, whose return value is what as binds, and __exit__(exc_type, exc, tb), which always runs and can return True to swallow the exception.

    Go deeper: Python Context Managers: The with Statement and Building Your Own

  4. 34.How does Python manage memory?

    CPython frees most objects by reference counting: each object counts the references to it (names, list items, attributes), and when the count drops to zero its memory is released at once. Reference counting alone cannot free a cycle, such as two objects that point at each other, so a cyclic garbage collector (the gc module) runs from time to time, finds groups of objects reachable only from each other, and frees them. Small objects come from Python's own allocator, which keeps memory pools for reuse instead of handing every freed block back to the operating system.

    memory_management.py
    import gc, weakref
    class Node: pass
    a = Node()
    weakref.finalize(a, print, "a freed by reference counting")
    del a # the count hits 0 on this line
    gc.disable() # pause automatic collection for the demo
    x, y = Node(), Node()
    x.partner, y.partner = y, x # a cycle: each keeps the other alive
    weakref.finalize(x, print, "x freed by the cycle collector")
    del x, y
    print("names deleted, the cycle is still in memory")
    gc.collect()
    gc.enable()
    Output
    a freed by reference counting
    names deleted, the cycle is still in memory
    x freed by the cycle collector

    Follow-up: Does del x delete the object? No, it removes the name x; the object is freed only when its last reference goes away.

    Go deeper: How Python Works Under the Hood: Memory, GIL, and Bytecode

  5. 35.What does __slots__ do?

    __slots__ declares a fixed set of attribute names, so instances store those values in compact slots instead of a per-instance __dict__. That saves memory when you create many small objects (millions of points or records) and makes attribute access slightly faster. The trade-off: you cannot add attributes that are not listed, which also turns a typo like p.nmae = 1 into an AttributeError instead of a silent new attribute.

    slots.py
    class Point:
    __slots__ = ("x", "y") # no per-instance __dict__
    def __init__(self, x, y):
    self.x = x
    self.y = y
    p = Point(2, 3)
    print(p.x, p.y)
    print("has __dict__:", hasattr(p, "__dict__"))
    try:
    p.z = 5 # z is not in __slots__
    except AttributeError as err:
    print("AttributeError:", err)
    Output
    2 3
    has __dict__: False
    AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes

    Follow-up: Does a subclass keep the saving? Only if it declares __slots__ too; otherwise its instances get a __dict__ again.

    Go deeper: Python __slots__: Reduce Memory Usage by 40%

  6. 36.What is the MRO, and how does super() work with multiple inheritance?

    The MRO (method resolution order) is the order in which Python searches a class and its bases for an attribute, and you can read it from Class.__mro__. It comes from the C3 algorithm, which puts every class before its parents and keeps the bases in the order you listed them. super() means the next class after the current one in the MRO of the instance's class, not simply the parent. That is why, in a diamond, each method calls super() once and every class in the chain runs exactly once.

    mro_and_super.py
    class Base:
    def hello(self): return ["Base"]
    class Left(Base):
    def hello(self): return ["Left"] + super().hello()
    class Right(Base):
    def hello(self): return ["Right"] + super().hello()
    class Child(Left, Right):
    def hello(self): return ["Child"] + super().hello()
    print([cls.__name__ for cls in Child.__mro__])
    print(Child().hello()) # super() in Left goes to Right, not Base
    Output
    ['Child', 'Left', 'Right', 'Base', 'object']
    ['Child', 'Left', 'Right', 'Base']

    Follow-up: When does Python refuse to build an MRO? When the bases contradict each other, for example class C(A, B) where B already inherits from A, which raises TypeError: Cannot create a consistent method resolution order.

    Go deeper: Python Multiple Inheritance and MRO: How It Actually Works

  7. 37.What is a metaclass?

    A metaclass is the class of a class. An object is created by calling its class, and a class is created by calling its metaclass, which is type unless you say otherwise. A custom metaclass subclasses type and overrides __new__ or __init__ to inspect or change every class as it is defined: registering plugins, enforcing naming rules, adding methods. They are rarely the right tool: class decorators and __init_subclass__ handle most of those jobs more simply.

    metaclasses.py
    Dog = type("Dog", (), {"sound": "woof"}) # a class made by calling type
    print(Dog().sound, type(Dog).__name__)
    class Registry(type):
    classes = []
    def __new__(mcls, name, bases, namespace):
    cls = super().__new__(mcls, name, bases, namespace)
    mcls.classes.append(name)
    return cls
    class Plugin(metaclass=Registry): pass
    class CsvExport(Plugin): pass # subclasses get the metaclass too
    class PdfExport(Plugin): pass
    print(Registry.classes, type(CsvExport).__name__)
    Output
    woof type
    ['Plugin', 'CsvExport', 'PdfExport'] Registry

    Follow-up: What is __init_subclass__? A hook on a base class that Python calls each time a subclass is defined, which covers plugin registration without a metaclass.

    Go deeper: Python Metaclasses: Control How Classes Are Created

  8. 38.What is the difference between __new__ and __init__?

    __new__ creates and returns the new object; __init__ then receives that object and sets its attributes, returning None. __new__ takes the class as its first argument and runs first, and Python calls __init__ only if __new__ returned an instance of that class. You override __new__ only when __init__ is too late: subclassing immutable types such as int, str, float or tuple, returning cached instances, or singletons.

    new_vs_init.py
    class Price(float):
    def __new__(cls, amount):
    print("1. __new__ builds the object")
    # float is immutable, so the value must be set here
    return super().__new__(cls, round(amount, 2))
    def __init__(self, amount):
    print("2. __init__ receives", self) # too late to change it
    price = Price(19.9949)
    print(price, price * 2)
    Output
    1. __new__ builds the object
    2. __init__ receives 19.99
    19.99 39.98

    Follow-up: What if __new__ returns an object of a different class? Python skips __init__ and the call returns that object unchanged.

    Go deeper: __init__ vs __new__: What Actually Happens When You Create an Object

  9. 39.What does @dataclass generate for you?

    From the annotated class attributes, @dataclass writes __init__, a readable __repr__ and a field-by-field __eq__. Options add more: order=True generates <, <=, > and >= (comparing the fields as a tuple, in order), frozen=True blocks assignment and adds __hash__, and slots=True adds __slots__. A mutable default needs field(default_factory=list): a plain [] default raises ValueError, which guards against the shared-default bug.

    dataclass.py
    from dataclasses import dataclass, field
    @dataclass(order=True)
    class Student:
    name: str
    marks: int = 0
    tags: list = field(default_factory=list)
    asha = Student("Asha", 91)
    ravi = Student("Ravi", 78, ["sports"])
    print(asha) # generated __repr__
    print(asha == Student("Asha", 91)) # generated __eq__
    print(asha < ravi) # order=True: "Asha" < "Ravi"
    Output
    Student(name='Asha', marks=91, tags=[])
    True
    True

    Follow-up: Why is a normal dataclass unhashable? It defines __eq__ without __hash__, so Python sets __hash__ to None; frozen=True gives it a hash built from the fields.

    Go deeper: Python Dataclasses: Less Boilerplate, More Readable Code

  10. 40.What is a property, and how does it relate to descriptors?

    A property makes a method look like an attribute: reading acct.balance calls a getter and assigning to it calls a setter, so you can add validation or computed values later without changing how callers use the class. property is a descriptor: an object stored on the class whose type defines __get__, __set__ or __delete__, which Python calls whenever the attribute is read or assigned on an instance. Methods, classmethod and staticmethod work the same way. When many attributes need the same logic, write your own descriptor class instead of repeating properties.

    property_and_descriptors.py
    class Account:
    @property
    def balance(self): return self._balance
    @balance.setter
    def balance(self, value):
    if value < 0: raise ValueError("balance cannot be negative")
    self._balance = value
    acct = Account()
    acct.balance = 100 # runs the setter
    print(acct.balance, Account.balance.__get__(acct)) # one getter, two ways
    try:
    acct.balance = -5
    except ValueError as err:
    print("ValueError:", err)
    Output
    100 100
    ValueError: balance cannot be negative

    Follow-up: Why does a property beat an instance attribute with the same name? It defines __set__, which makes it a data descriptor, and data descriptors on the class take priority over the instance __dict__; plain functions are non-data descriptors, so an instance attribute can shadow a method.

    Go deeper: Python Descriptors: The Secret Behind @property and ORM Fields

  11. 41.How does functools.lru_cache work, and when is it unsafe?

    functools.lru_cache stores each result in a dict keyed by the call arguments, so a repeat call returns the saved value without running the function. With a maxsize (128 by default) it drops the least recently used entry when full; maxsize=None, or functools.cache, never drops anything. It is unsafe when the result depends on anything besides the arguments (the clock, a file, a database, global state), because callers get stale answers. It also returns the same object every time, so a cached list that one caller modifies is changed for everyone, and caching a method keeps every self alive in the cache.

    lru_cache.py
    from functools import lru_cache
    @lru_cache(maxsize=None)
    def square(n):
    print(f"computing {n}")
    return n * n
    print(square(4), square(4)) # computed once, then served from the cache
    @lru_cache
    def default_tags(user):
    return ["new"]
    default_tags("asha").append("admin") # changes the cached list itself
    print(default_tags("asha")) # every later caller sees it
    Output
    computing 4
    16 16
    ['new', 'admin']

    Follow-up: Why does passing a list raise TypeError? The arguments become a dict key, so they must be hashable; pass a tuple instead.

    Go deeper: Python functools: lru_cache, partial, reduce, singledispatch

  12. 42.What is duck typing, and what are type hints for?

    Duck typing means code depends on what an object can do, not on its class: any object with the method you call will work. Type hints, such as def total(prices: list[float]) -> float, document the intended types and let tools like mypy, pyright and your editor catch mistakes before the code runs. Python does not check them at runtime; it only stores them in __annotations__. typing.Protocol joins the two ideas: it describes a duck type (anything with a speak() method) that a type checker can verify without inheritance.

    duck_typing_and_type_hints.py
    class Duck:
    def speak(self): return "Quack"
    class Robot: # unrelated class, same method
    def speak(self): return "Beep"
    for thing in (Duck(), Robot()):
    print(thing.speak()) # works for anything with speak()
    def double(n: int) -> int:
    return n * 2
    print(double("ha")) # hints are not enforced at runtime
    print(double.__annotations__)
    Output
    Quack
    Beep
    haha
    {'n': <class 'int'>, 'return': <class 'int'>}

    Follow-up: How would you enforce types at runtime? With explicit isinstance checks, or a library such as pydantic that reads the hints and validates incoming data.

    Go deeper: Python Type Hints: Add Types, Generics, and the typing Module

  13. 43.What do * and ** unpacking do, and what are keyword-only arguments?

    In a call, *items spreads a sequence into positional arguments and **options spreads a dict into keyword arguments. In a definition they do the reverse: *args collects extra positional arguments into a tuple and **kwargs collects extra keyword arguments into a dict. Parameters after *args or a bare * are keyword-only: callers must pass them by name, which keeps calls such as sort(reverse=True) readable and lets you add parameters later without breaking existing calls.

    unpacking_and_keyword_only.py
    def order(item, *extras, qty=1, **notes): # qty is keyword-only
    print(item, extras, qty, notes)
    order("pizza", "olives", "chilli", qty=2, table=7)
    order(*["tea"], **{"qty": 3, "sugar": False}) # spread a list and a dict
    def connect(host, *, timeout=10): # bare *: timeout is keyword-only
    return f"{host} (timeout {timeout})"
    print(connect("db", timeout=5))
    try:
    connect("db", 5)
    except TypeError as err:
    print("TypeError:", err)
    Output
    pizza ('olives', 'chilli') 2 {'table': 7}
    tea () 3 {'sugar': False}
    db (timeout 5)
    TypeError: connect() takes 1 positional argument but 2 were given

    Follow-up: What does a / in a signature mean? Parameters before it are positional-only, as in def area(width, height, /), so callers cannot pass them by name.

    Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments

  14. 44.How do __eq__ and __hash__ relate?

    Objects that compare equal must have equal hashes, because a dict or set finds a key by its hash first and only then checks ==. By default both are based on identity, so they agree. If a class defines __eq__ without __hash__, Python sets __hash__ to None and its instances become unhashable, so you cannot break the rule by accident. Hash the same fields that __eq__ compares, and only fields that never change: if a key's hash changes after it is stored, the dict can no longer find it.

    eq_and_hash.py
    class Point:
    def __init__(self, x, y):
    self.x, self.y = x, y
    def __eq__(self, other):
    if not isinstance(other, Point): return NotImplemented
    return (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
    return hash((self.x, self.y)) # the same fields as __eq__
    points = {Point(1, 2), Point(1, 2), Point(3, 4)}
    print(len(points), Point(1, 2) in points)
    class Tag:
    def __eq__(self, other): return True # __eq__ but no __hash__
    print(Tag.__hash__) # None: instances are unhashable
    Output
    2 True
    None

    Follow-up: Why are lists unhashable? They are mutable, so their hash would change with their contents; use a tuple as a dict key instead.

    Go deeper: Python Magic Methods: The Complete __dunder__ Guide

  15. 45.What is monkey patching, and why is it risky?

    Monkey patching means replacing an attribute of a module, class or object at runtime, for example swapping a library function for your own. It is useful in tests, where unittest.mock.patch swaps something in and puts it back afterwards, and for hot-fixing a bug in a third-party library. It is risky because the change is global and invisible where the function is called: every caller in the program sees it, it breaks silently when the library changes its internals, and code that ran from math import sqrt before the patch still holds the original.

    monkey_patching.py
    import math
    from math import sqrt # this name was bound before the patch
    original = math.sqrt
    math.sqrt = lambda x: "patched!" # every math.sqrt caller now gets this
    print(math.sqrt(16))
    print(sqrt(16)) # the early import still has the original
    math.sqrt = original # always put it back
    from unittest.mock import patch
    with patch("math.sqrt", return_value=99): # patches, then restores itself
    print(math.sqrt(16))
    print(math.sqrt(16))
    Output
    patched!
    4.0
    99
    4.0

    Follow-up: How do you patch safely in tests? Use unittest.mock.patch as a context manager or decorator, and patch the name where it is looked up (myapp.orders.send_email), not where it is defined.

Coding questions

Short tasks to write live, each with a solution and its complexity.

  1. 46.Write a function that checks whether a string is a palindrome, ignoring case, spaces and punctuation.

    Keep only the letters and digits, lowercase them, and compare the result with its reverse. str.isalnum drops spaces and punctuation, and slicing with [::-1] reverses the list. It runs in O(n) time and uses O(n) extra space for the cleaned copy.

    palindrome_check.py
    def is_palindrome(text):
    cleaned = [ch.lower() for ch in text if ch.isalnum()]
    return cleaned == cleaned[::-1]
    print(is_palindrome("A man, a plan, a canal: Panama!"))
    print(is_palindrome("Hello"))
    print(is_palindrome("")) # an empty string reads the same both ways
    Output
    True
    False
    True

    Follow-up: Can you do it with O(1) extra space? Yes: move two indexes inward from both ends, skip characters that are not alphanumeric, and compare as you go.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  2. 47.Write a function that returns the first non-repeating character in a string.

    Count every character with collections.Counter in one pass, then walk the string again and return the first character whose count is 1. Two passes make it O(n) time, and the counter holds one entry per distinct character. Return None when every character repeats.

    first_non_repeating_character.py
    from collections import Counter
    def first_unique(text):
    counts = Counter(text)
    for ch in text:
    if counts[ch] == 1:
    return ch
    return None
    print(first_unique("swiss"))
    print(first_unique("level"))
    print(first_unique("aabb")) # every character repeats
    Output
    w
    v
    None

    Follow-up: Why not call text.count(ch) inside the loop? Each call scans the whole string, so that version is O(n^2).

    Go deeper: Python collections: Counter, defaultdict, deque, and More

  3. 48.Write a function that counts how often each word appears in a sentence.

    Lowercase the text, pull out the words with a regular expression so punctuation does not stick to them, and count them with collections.Counter, a dict subclass that maps each word to its count. It runs in O(n) time for a text of length n. most_common(k) gives the top words, and a missing word counts as 0 instead of raising KeyError.

    word_frequency.py
    import re
    from collections import Counter
    def word_counts(sentence):
    words = re.findall(r"[a-z']+", sentence.lower())
    return Counter(words)
    counts = word_counts("The cat saw the dog. The dog didn't see the cat!")
    print(counts.most_common(2))
    print(counts["dog"], counts["bird"]) # a missing word counts as 0
    print(word_counts(""))
    Output
    [('the', 4), ('cat', 2)]
    2 0
    Counter()

    Follow-up: How would you do it without Counter? Loop over the words on a plain dict with counts[word] = counts.get(word, 0) + 1.

    Go deeper: Python collections: Counter, defaultdict, deque, and More

  4. 49.Two sum: given a list of numbers and a target, return the indexes of two numbers that add up to the target.

    Walk the list once, keeping a dict that maps each number seen so far to its index. For each number, check whether target - num is already in the dict: if it is, you have the pair. That is O(n) time and O(n) space, against O(n^2) for checking every pair with two loops.

    two_sum.py
    def two_sum(nums, target):
    seen = {} # number -> its index
    for i, num in enumerate(nums):
    if target - num in seen:
    return seen[target - num], i
    seen[num] = i
    return None
    print(two_sum([2, 7, 11, 15], 9))
    print(two_sum([3, 3], 6)) # the same value twice
    print(two_sum([1, 2, 3], 100)) # no pair adds up
    Output
    (0, 1)
    (0, 1)
    None

    Follow-up: What if the list is sorted? Use two pointers from both ends, moving the left one up when the sum is too small and the right one down when it is too big: O(n) time and O(1) space.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  5. 50.Write FizzBuzz: count from 1 to n, but say Fizz for multiples of 3, Buzz for multiples of 5 and FizzBuzz for multiples of both.

    The trap is order: with an if/elif chain you must test for 15 (both) before 3 and 5, or 15 comes out as Fizz. Building the word from parts sidesteps that, and adding a new rule becomes one more line. It runs in O(n) time.

    fizzbuzz.py
    def fizzbuzz(n):
    result = []
    for i in range(1, n + 1):
    word = ("Fizz" if i % 3 == 0 else "") + ("Buzz" if i % 5 == 0 else "")
    result.append(word or str(i))
    return result
    print(", ".join(fizzbuzz(15)))
    print(fizzbuzz(0)) # nothing to count
    Output
    1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz
    []

    Follow-up: How would you add Bazz for multiples of 7? Keep the rules as a list of (divisor, word) pairs and join the words whose divisor matches, so a new rule is one more pair.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  6. 51.Write a function that reverses the order of words in a sentence.

    Split the sentence into words with split(), reverse the list, and join it back with single spaces. split() with no argument splits on any run of whitespace and ignores leading and trailing spaces, so messy input still gives clean output. It runs in O(n) time and space.

    reverse_words.py
    def reverse_words(sentence):
    return " ".join(reversed(sentence.split()))
    print(reverse_words("Python is fun"))
    print(reverse_words(" hello world ")) # extra spaces disappear
    print(repr(reverse_words("")))
    Output
    fun is Python
    world hello
    ''

    Follow-up: Can you do it in place? Not on a str, which is immutable; on a list of characters, reverse the whole list and then reverse each word back.

    Go deeper: Every Python String Method Explained with Examples

  7. 52.Write a function that checks whether two strings are anagrams.

    Two strings are anagrams when they contain the same characters the same number of times, so compare Counter(a) == Counter(b), which is O(n) time. sorted(a) == sorted(b) is shorter but O(n log n). Ask whether case and spaces count; here both are ignored.

    anagram_check.py
    from collections import Counter
    def clean(text):
    return text.replace(" ", "").lower()
    def is_anagram(a, b):
    return Counter(clean(a)) == Counter(clean(b))
    print(is_anagram("Listen", "Silent"))
    print(is_anagram("Dormitory", "Dirty room"))
    print(is_anagram("aab", "abb")) # same letters, different counts
    Output
    True
    True
    False

    Follow-up: What if the strings hold only lowercase English letters? A list of 26 counts indexed by ord(ch) - 97 does the same job with O(1) extra space.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  8. 53.Find the missing number in a list that holds every number from 1 to n except one.

    The numbers 1 to n add up to n * (n + 1) // 2, so the missing one is that total minus the sum of the list, with n one more than the list's length. That is O(n) time and O(1) extra space, and Python integers never overflow. In languages with fixed-size integers, XOR-ing everything with 1 to n gives the same answer without a large sum.

    missing_number.py
    def missing_number(nums):
    n = len(nums) + 1 # one number is missing
    return n * (n + 1) // 2 - sum(nums)
    print(missing_number([3, 7, 1, 2, 8, 4, 5]))
    print(missing_number([1, 2, 3])) # the last number is the missing one
    print(missing_number([])) # n is 1, so 1 is missing
    Output
    6
    4
    1

    Follow-up: What if two numbers are missing? Use the sum and the sum of squares to get two equations, or put the list in a set and check every number from 1 to n against it.

  9. 54.Write a function that flattens a nested list of any depth.

    Recurse: for each item, if it is a list, flatten it and pass its items on, otherwise pass the item itself. A generator with yield from keeps it short and lazy. It is O(n) in the total number of items and sublists. Nesting deeper than about 1,000 levels hits the recursion limit.

    flatten_nested_list.py
    def flatten(items):
    for item in items:
    if isinstance(item, list):
    yield from flatten(item)
    else:
    yield item
    print(list(flatten([1, [2, [3, [4, [5]]]], 6])))
    print(list(flatten([[], [[]], "ab"]))) # strings stay whole
    print(list(flatten([])))
    Output
    [1, 2, 3, 4, 5, 6]
    ['ab']
    []

    Follow-up: How do you flatten without recursion? Keep a stack of iterators: push a sublist when you meet one and pop when its iterator runs out, so depth is limited only by memory.

    Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory

  10. 55.Find the second largest number in a list.

    Track the largest and second largest distinct values in one pass: O(n) time and O(1) space. sorted(set(nums))[-2] is shorter but O(n log n). Agree on duplicates with the interviewer: for [5, 5, 3] the usual answer is 3, and a list without two distinct values has no answer, so return None.

    second_largest.py
    def second_largest(nums):
    first = second = None
    for n in nums:
    if first is None or n > first:
    first, second = n, first
    elif n != first and (second is None or n > second):
    second = n
    return second
    print(second_largest([12, 45, 2, 41, 45]))
    print(second_largest([5, 5, 3])) # duplicates of the largest are skipped
    print(second_largest([7, 7])) # no second distinct value
    Output
    41
    3
    None

    Follow-up: How would you find the k-th largest? heapq.nlargest(k, set(nums))[-1] keeps only k items in a heap, which is O(n log k).

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  11. 56.Merge two sorted lists into one sorted list.

    Walk both lists with two indexes, always taking the smaller front item, then add whatever is left of the list that did not run out. That is O(n + m) time, which is the point of the question: sorted(a + b) works but costs O((n + m) log(n + m)). Using <= takes from the first list on ties, so equal items keep their order.

    merge_sorted_lists.py
    def merge_sorted(a, b):
    result, i, j = [], 0, 0
    while i < len(a) and j < len(b):
    if a[i] <= b[j]:
    result.append(a[i])
    i += 1
    else:
    result.append(b[j])
    j += 1
    return result + a[i:] + b[j:] # one of these is already empty
    print(merge_sorted([1, 4, 9], [2, 3, 10, 12]))
    print(merge_sorted([], [5, 6]))
    print(merge_sorted([2, 2], [2]))
    Output
    [1, 2, 3, 4, 9, 10, 12]
    [5, 6]
    [2, 2, 2]

    Follow-up: Is there a built-in? heapq.merge(a, b) merges any number of sorted iterables lazily, one item at a time.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  12. 57.Write a function that checks whether the brackets in a string are balanced.

    Use a stack: push every opening bracket, and on a closing bracket check that the stack is not empty and that the bracket popped from the top is its partner. The string is balanced if the stack is empty at the end. Other characters are skipped. It runs in O(n) time and O(n) space in the worst case.

    balanced_brackets.py
    def is_balanced(text):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in text:
    if ch in "([{":
    stack.append(ch)
    elif ch in pairs:
    if not stack or stack.pop() != pairs[ch]:
    return False
    return not stack
    print(is_balanced("{[a + b] * (c - d)}"))
    print(is_balanced("([)]")) # right counts, wrong order
    print(is_balanced("((")) # never closed
    Output
    True
    False
    False

    Follow-up: Why not just count opening and closing brackets? A count checks the totals but not the order, so ([)] would pass; the stack remembers which bracket must close next.

    Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)

  13. 58.Write a Fibonacci function that uses memoization.

    Plain recursive Fibonacci recomputes the same values over and over, so its running time grows exponentially. Memoization stores each result the first time it is computed, so every fib(k) is worked out once and the whole call is O(n) time and O(n) space. In real code, @functools.cache on the plain recursive version adds the cache in one line.

    fibonacci_memoization.py
    def fib(n, memo=None):
    if memo is None:
    memo = {}
    if n < 2:
    return n
    if n not in memo:
    memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
    return memo[n]
    print([fib(i) for i in range(10)])
    print(fib(90)) # instant; uncached, this would take thousands of years
    Output
    [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
    2880067194370816120

    Follow-up: Can you do it without recursion? Yes: keep only the last two values and run a, b = b, a + b n times, which is O(n) time, O(1) space and has no recursion limit.

    Go deeper: Python Recursion: How It Works, When to Use It, and Examples

  14. 59.Move all zeros in a list to the end, keeping the order of the other numbers.

    Keep a write index: copy each non-zero value forward to that index, then fill the rest of the list with zeros. Every item is visited once, so it is O(n) time and O(1) extra space, done in place. If a new list is fine, [n for n in nums if n != 0] padded with the right number of zeros is just as fast.

    move_zeros_to_end.py
    def move_zeros(nums):
    write = 0
    for num in nums:
    if num != 0:
    nums[write] = num
    write += 1
    for i in range(write, len(nums)):
    nums[i] = 0
    return nums
    print(move_zeros([0, 1, 0, 3, 12]))
    print(move_zeros([0, 0, 0]))
    print(move_zeros([4, 2])) # no zeros: unchanged
    Output
    [1, 3, 12, 0, 0]
    [0, 0, 0]
    [4, 2]

    Follow-up: Why not nums.sort(key=lambda n: n == 0)? It works, because the sort is stable and keeps the non-zero order, but it is O(n log n) instead of O(n).

    Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists

  15. 60.Group words that are anagrams of each other.

    Anagrams have the same letters once sorted, so use the sorted letters as a dict key and append each word to its group; defaultdict(list) creates each group on first use. For n words of up to k letters that is O(n k log k) time. Using a tuple of 26 letter counts as the key removes the sort and makes it O(n k).

    group_anagrams.py
    from collections import defaultdict
    def group_anagrams(words):
    groups = defaultdict(list)
    for word in words:
    key = "".join(sorted(word)) # "eat" and "tea" both become "aet"
    groups[key].append(word)
    return list(groups.values())
    print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
    print(group_anagrams(["solo"]))
    print(group_anagrams([]))
    Output
    [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
    [['solo']]
    []

    Follow-up: Why is the key a string and not the sorted list itself? Dict keys must be hashable, and a list is not; a string or a tuple is.

    Go deeper: Python Dictionaries: The Complete Guide with Real Examples

Practise against the clock

A timed mock interview gives you two problems and a clock, and the 50 practice problems check your code as you go.