Ch. 09

Python interview questions & answers

Python interviews in one place: data types and mutability, comprehensions, generators, decorators, OOP, the GIL, async and the standard library.

60 interview questions22 quiz questions28 notes
your progress0%

Notes in this chapter

Filter all notes →
read ✓Python · hard

Python asyncio.gather and Timeouts

Run coroutines concurrently with asyncio.gather, enforce timeouts, and handle partial failures and cancellation correctly.

~2 min readread →
read ✓Python · mid

Python Bounded Async Worker Queues

Use asyncio.Queue and a fixed worker pool to bound pending work. Trace backpressure, shutdown and failure ownership.

~3 min readread →
read ✓Python · mid

Python Asyncio and Blocking Functions

Why time.sleep or a sync HTTP call inside a coroutine freezes every task, how to spot it with debug mode, and when to_thread is enough.

~6 min readread →
read ✓Python · mid

Python Asyncio Tasks and Cancellation

Who owns an asyncio task: why fire-and-forget loses errors, how TaskGroup cancels siblings, and why swallowing CancelledError hangs shutdown.

~6 min readread →
read ✓Python · mid

Python Closures and Late Binding

Why lambdas created in a loop all return the last value, what a closure cell really stores, and the three clean ways to capture a value.

~5 min readread →
read ✓Python · easy

Python Shallow Copies and Nested Mutability

Assignment, shallow copy and deepcopy compared on a nested config dict, plus the [[0]*3]*3 grid bug and what deepcopy costs in practice.

~5 min readread →
read ✓Python · mid

Python Dataclasses and Mutable Fields

Why a list default fails in a dataclass, what frozen=True does and doesn't protect, and how eq and hash decide if instances can be keys.

~5 min readread →
read ✓Python · mid

Python Decorators and Function Metadata

A decorator replaces your function, so it owes callers the same return value, exceptions and metadata. functools.wraps and async cases.

~5 min readread →
read ✓Python · easy

Python Generators and Single-Pass Iteration

A generator runs nothing until the first next(): why validation fires late, how lazy pipelines pull one item at a time, and why they empty.

~5 min readread →
read ✓Python · mid

Python Hashability and Dictionary Keys

What makes a Python object usable as a dict key or set member, why defining __eq__ removes __hash__, and how a mutated key gets lost.

~5 min readread →
read ✓Python · easy

Python Identity Versus Equality

is checks for the same object and == asks the object whether values match. Small-int caching, interning, None checks and sentinels.

~5 min readread →
read ✓Python · hard

Python Imports and Side Effects

An import runs the module's top-level code once and caches it. Config read too early, circular imports, __main__ guards and importtime.

~6 min readread →
read ✓Python · easy

Python Iterators and Exhaustion

Iterables versus iterators: why map, zip, files and csv readers empty after one pass, how zip and in eat items, and how to design for it.

~5 min readread →
read ✓Python · easy

Python Mutable Defaults and Shared State

Why def f(x, items=[]) keeps growing between calls, where Python stores that list, the None-sentinel fix, and why tags or [] is subtly wrong.

~5 min readread →
read ✓Python · hard

Python Pathlib and Safe File Selection

Joining a user-supplied name onto a base Path can escape it via .., absolute paths or symlinks. Contain it with resolve() and is_relative_to.

~5 min readread →
read ✓Python · mid

Python Stable Sorting and Key Functions

How Python's stable sort keeps ties in order, multi-key sorts with tuples or two passes, reverse=True versus [::-1], and None in keys.

~5 min readread →
read ✓Python · hard

Python Test Fixtures and State Isolation

Why a test passes alone and fails in the suite: leaked module state, environment and files. Fix it with fixtures, scopes and cleanups.

~6 min readread →

60 Python interview questions study by subtopic

60 questions
  1. 1.What is the difference between a list and a tuple in Python? When would you use each?easy

    Both are ordered sequences that support indexing, slicing and iteration, and both can hold mixed types. The core difference is mutability:

    • A list is mutable: you can append, remove and sort it in place. It's a dynamic array that over-allocates, so append is amortized O(1).
    • A tuple is immutable once created. Because of that it's hashable (as long as all its elements are hashable), so it can be a dict key or a set member, and it's slightly smaller and cheaper to create.

    There's also a convention: a list is a variable-length collection of similar things ([user1, user2, ...]), while a tuple is a fixed-size record where position has meaning ((lat, lon)). That's why functions return multiple values as tuples.

    One gotcha: tuple immutability is shallow. In t = ([1], 2) you can't reassign t[0], but t[0].append(3) works, and hash(t) raises TypeError because the list inside is unhashable.

    What interviewers listen for
    • List is mutable; tuple is immutable
    • Tuples are hashable if all their elements are
    • Tuples can be dict keys and set members
    • List for collections, tuple for fixed-position records
    • Tuple immutability is shallow

    Likely follow-up: How do you create a one-element tuple? · Why is ([1], 2) not usable as a dict key?

  2. 2.What are mutable and immutable types in Python? Give examples and explain why the distinction matters.easy

    A mutable object can be changed in place after it's created; an immutable one can't, so every "change" produces a new object.

    • Immutable: int, float, complex, bool, str, bytes, tuple, frozenset, None
    • Mutable: list, dict, set, bytearray, and most instances of user-defined classes

    It matters because Python variables are just names bound to objects. If two names refer to the same list, a mutation through one is visible through the other. With immutable types that can't happen: s += "!" on a string builds a new string and rebinds s, while lst += [1] extends the existing list in place, so every alias sees it.

    Immutability is also what makes built-in objects safely hashable, which is why dict keys and set members are things like strings, numbers and tuples, never lists. And it's the root cause of the mutable-default-argument bug and of functions that surprise callers by mutating their arguments.

    What interviewers listen for
    • Mutable objects change in place; immutable ones create new objects
    • Immutable: int, float, str, bytes, tuple, frozenset
    • Mutable: list, dict, set, bytearray, most instances
    • Aliased names all see a mutation
    • Hashability (dict keys, sets) relies on immutability

    Likely follow-up: What does += do differently for a list and a string? · Can a tuple ever change?

  3. 3.What is a decorator in Python? Write a simple one and explain why functools.wraps is used.mid

    A decorator is a callable that takes a function and returns a replacement, usually a wrapper that adds behavior before or after the original call. The @timed syntax is just sugar for slow = timed(slow), applied once when the function is defined.

    The wrapper accepts *args, **kwargs so it works with any signature, calls the original, and returns its result. Forgetting that return is a classic bug: the decorated function silently returns None.

    functools.wraps copies the original's metadata (__name__, __qualname__, __doc__, __module__ and a few more), merges its __dict__, and sets __wrapped__ pointing to the original. Without it every decorated function reports its name as wrapper, which hurts logging, debugging, generated docs and any tool that introspects functions.

    Common uses: logging, timing, caching (functools.lru_cache), retries, authorization checks and route registration in web frameworks. Stacked decorators apply bottom-up: the one closest to the def wraps first.

    import functools, time
    
    def timed(func):
        @functools.wraps(func)            # copy __name__, __doc__ etc., set __wrapped__
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
            return result
        return wrapper
    
    @timed                                # same as: slow = timed(slow)
    def slow(n):
        return sum(range(n))
    What interviewers listen for
    • Takes a function, returns a replacement callable
    • @dec is sugar for f = dec(f) at definition time
    • Wrapper uses *args, **kwargs and returns the result
    • functools.wraps preserves name, docstring, sets __wrapped__
    • Stacked decorators apply bottom-up

    Likely follow-up: How would you write a decorator that takes arguments? · How do you decorate a method, or a class?

  4. 4.What is a generator, and how does yield work? Why would you use one instead of returning a list?mid

    A generator function is any function that contains yield. Calling it doesn't run the body; it returns a generator object, which is an iterator. Each next() resumes execution until the next yield, hands that value out, and freezes the frame (local variables and position) until the next request. When the function returns, the generator raises StopIteration, which for loops handle for you.

    Why use one:

    • Lazy evaluation: values are produced on demand, so memory stays flat even for huge or infinite sequences, like streaming lines from a large file.
    • Pipelines: chain generators so each item flows through every stage one at a time.
    • Simpler iterators: no class with __iter__ and __next__ needed.

    Trade-offs: a generator is single-pass (once exhausted it stays empty), with no len() and no indexing. Generators also support send(), throw() and close(), the machinery Python's coroutines were originally built on.

    def countdown(n):
        print("starting")
        while n > 0:
            yield n            # pause here and hand n to the caller
            n -= 1
    
    gen = countdown(3)         # nothing printed yet: the body hasn't started
    print(next(gen))           # prints "starting", then 3
    print(list(gen))           # [2, 1]
    print(next(gen, "done"))   # done (the generator is exhausted)
    What interviewers listen for
    • Calling a generator function returns an iterator, runs nothing
    • yield produces a value and suspends the frame
    • Lazy: constant memory, works for infinite streams
    • Raises StopIteration when the function returns
    • Single-pass: exhausted generators stay empty

    Likely follow-up: What does yield from do? · How is a generator different from a generator expression?

  5. 5.What is wrong with using a mutable object like [] as a default argument, and how do you fix it?easy

    Default values are evaluated once, when the def statement executes, not on every call. The default list is stored on the function object (you can inspect it in add_item.__defaults__), so every call that omits bucket mutates that same list and state leaks from one call to the next.

    The standard fix is a None sentinel: default to None and create a fresh object inside the function. If None is a meaningful value for callers, use a private sentinel such as _MISSING = object() and compare with is.

    The same applies to any mutable default: dicts, sets, or class instances. Immutable defaults like 0, "" or () are safe because they can't be modified.

    Tooling helps here: linters such as Pylint (and Ruff with its bugbear rules) warn about mutable defaults, and dataclasses refuses a bare [] field default with a ValueError, forcing you to write field(default_factory=list).

    def add_item(item, bucket=[]):     # [] is created once, when def runs
        bucket.append(item)
        return bucket
    
    print(add_item(1))   # [1]
    print(add_item(2))   # [1, 2]  <- the same list again!
    
    def add_item(item, bucket=None):   # the fix: a None sentinel
        if bucket is None:
            bucket = []                # fresh list on every call
        bucket.append(item)
        return bucket
    What interviewers listen for
    • Defaults are evaluated once, at function definition
    • The same object is shared across calls
    • Fix: default to None, create inside the function
    • Use a private object() sentinel if None is valid
    • Dataclasses require field(default_factory=list)

    Likely follow-up: Where does Python store default values? · Is it ever useful to rely on this behaviour?

  6. 6.What is the difference between is and == in Python?easy

    == checks equality: it calls __eq__, so two different objects with the same value compare equal. is checks identity: whether both operands are the very same object, equivalent to id(a) == id(b) while both are alive.

    So [1, 2] == [1, 2] is True, but [1, 2] is [1, 2] is False because those are two separate lists.

    Rules of thumb:

    • Use is / is not for singletons: None, and sentinel objects you create yourself. PEP 8 says comparisons to None should always use is or is not.
    • Use == for everything else, including numbers and strings.

    Never rely on is for ints or strings. CPython caches small integers and interns some strings, so is sometimes happens to return True, but that's an implementation detail. Since Python 3.8, x is 5 even triggers a SyntaxWarning. Also, __eq__ can be overridden to return anything, while is can't be overridden at all.

    What interviewers listen for
    • == compares values via __eq__
    • is compares object identity
    • Use is for None and sentinels
    • Small-int and string caching are implementation details
    • is cannot be overridden; __eq__ can

    Likely follow-up: Why might a is b be True for 256 but not for 257? · Why is x == None discouraged?

  7. 7.What do *args and **kwargs mean in a function definition and in a function call?easy

    In a definition, *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. The names are only convention; the * and ** are what matter.

    The parameter order is: regular parameters, *args, keyword-only parameters, then **kwargs. Anything after *args (like sep above) can only be passed by keyword. A bare * forces keyword-only parameters without collecting extras, and / (Python 3.8+) marks the parameters before it as positional-only, e.g. def f(a, b, /, *, key).

    At a call site the same operators work in reverse: *iterable spreads items into positional arguments and **mapping spreads into keyword arguments.

    The classic real use is forwarding everything unchanged with func(*args, **kwargs), as decorators and wrapper functions do, or passing extra options up the chain with super().__init__(**kwargs).

    def log(level, *args, sep=" ", **kwargs):
        print(level, args, sep, kwargs)
    
    log("INFO", 1, 2, sep="|", user="ana")
    # INFO (1, 2) | {'user': 'ana'}
    
    nums = [1, 2]
    opts = {"sep": "-"}
    log("DEBUG", *nums, **opts)     # unpacking at the call site
    # DEBUG (1, 2) - {}
    What interviewers listen for
    • *args is a tuple of extra positional arguments
    • **kwargs is a dict of extra keyword arguments
    • Parameters after *args or bare * are keyword-only
    • / marks positional-only parameters (3.8+)
    • At call sites, * and ** unpack

    Likely follow-up: What error do you get when passing the same argument twice? · Why would an API use positional-only parameters?

  8. 8.What is the Global Interpreter Lock (GIL), and how does it affect multithreaded Python programs?mid

    The GIL is a mutex in CPython that allows only one thread at a time to execute Python bytecode in an interpreter. It exists mainly because CPython manages memory with reference counting, and one global lock made refcount updates and the C API thread-safe cheaply, which also made C extensions easy to write.

    Consequences:

    • CPU-bound pure-Python code doesn't get faster with threads. Use multiprocessing or ProcessPoolExecutor, or move the hot loop into C extensions such as NumPy that release the GIL.
    • I/O-bound code still benefits from threads, because the GIL is released while a thread waits on sockets, files or time.sleep().
    • The GIL doesn't make your code thread-safe. counter += 1 is several bytecode steps and isn't atomic, so shared state still needs a threading.Lock.

    It's a CPython implementation detail, not a language rule. CPython 3.13 introduced an experimental free-threaded build without the GIL, and 3.14 made it officially supported, but the default build still has a GIL.

    What interviewers listen for
    • One thread runs Python bytecode at a time per interpreter
    • Exists to protect reference counting and the C API
    • Threads help I/O-bound work, not CPU-bound work
    • CPU-bound: use processes or GIL-releasing C extensions
    • The GIL does not make your code thread-safe

    Likely follow-up: Why is counter += 1 not thread-safe even with the GIL? · What is free-threaded Python?

  9. 9.What is the difference between assignment, a shallow copy and a deep copy?mid

    Assignment (b = a) copies nothing; it just adds a second name for the same object. A shallow copy creates a new outer container but fills it with references to the same inner objects. A deep copy recursively copies everything, so the result shares no mutable state with the original.

    Ways to shallow-copy: copy.copy(x), lst[:], list(lst), lst.copy(), dict(d), d.copy(), {**d}. For a deep copy, copy.deepcopy(x).

    In the example, appending to orig[0] shows up in the shallow copy because both outer lists point at the same inner list.

    Details worth knowing: deepcopy keeps a memo dict, so shared references and cycles are reproduced rather than recursing forever. Classes can customize copying with __copy__ and __deepcopy__. Immutable objects like ints and strings aren't actually duplicated, since sharing them is safe. Deep copies are slow and can copy far more than you meant to, so a shallow copy plus rebuilding only the nested part you change is often better.

    import copy
    
    orig = [[1, 2], [3, 4]]
    alias = orig                     # no copy at all: same object
    shallow = copy.copy(orig)        # also orig[:], list(orig), orig.copy()
    deep = copy.deepcopy(orig)
    
    orig[0].append(99)
    print(alias is orig)   # True
    print(shallow)         # [[1, 2, 99], [3, 4]]: inner lists are shared
    print(deep)            # [[1, 2], [3, 4]]: fully independent
    What interviewers listen for
    • Assignment creates an alias, not a copy
    • Shallow copy: new container, shared inner objects
    • Deep copy: recursively copies all nested objects
    • deepcopy uses a memo to handle cycles
    • Customize with __copy__ / __deepcopy__

    Likely follow-up: Does slicing a list create a deep or shallow copy? · When would deepcopy be a bad idea?

  10. 10.What is the difference between a list comprehension and a generator expression? When would you use each?easy

    A list comprehension [x * x for x in data] runs immediately and builds the whole list in memory. A generator expression (x * x for x in data) looks almost identical but returns a lazy iterator that computes one item at a time as it's consumed.

    Choose based on how the result is used:

    • Need to index it, take len(), iterate it several times, or keep it around: use a list.
    • Just feeding it once into sum(), any(), max(), "".join() or a for loop: use a generator. Memory stays constant and any() can stop early. As the sole argument you can drop the extra parentheses: sum(x * x for x in data).

    For a million items the list takes megabytes, while the generator object is a couple of hundred bytes. For small inputs a list comprehension can be slightly faster.

    Gotchas: a generator is exhausted after one pass. In Python 3 both forms have their own scope, so the loop variable doesn't leak into the surrounding code. Set and dict comprehensions are eager, like list comprehensions.

    What interviewers listen for
    • List comprehension is eager and builds everything
    • Generator expression is lazy, one item at a time
    • Generators use constant memory, but only one pass
    • Use generators for one-shot sum, any, join
    • Loop variables do not leak in Python 3

    Likely follow-up: Why does iterating a generator expression a second time give nothing? · Is there a tuple comprehension?

  11. 11.What are the main built-in data types in Python?easy

    Grouped by category:

    • Numeric: int (arbitrary precision, never overflows), float (a C double, IEEE 754), complex, and bool, which is a subclass of int (True + True == 2).
    • Text and binary: str (immutable Unicode text), bytes (immutable) and bytearray (mutable).
    • Sequences: list (mutable), tuple (immutable) and range (a lazy arithmetic sequence).
    • Sets: set (mutable) and frozenset (immutable and hashable).
    • Mapping: dict, which preserves insertion order.
    • None, the single instance of NoneType.

    Worth adding in an interview: Python is dynamically but strongly typed, so "1" + 1 raises TypeError instead of coercing. Everything is an object, including functions and classes. type(x) gives the exact type, while isinstance(x, T) also accepts subclasses and is usually what you want. And 0.1 + 0.2 == 0.3 is False because floats are binary fractions; use math.isclose or decimal.Decimal when it matters.

    What interviewers listen for
    • Numeric: int (unbounded), float, complex, bool
    • Text and bytes: str, bytes, bytearray
    • Containers: list, tuple, range, set, frozenset, dict
    • Dynamically but strongly typed
    • Prefer isinstance over type(x) == checks

    Likely follow-up: Why is bool a subclass of int? · How would you represent money precisely?

  12. 12.Is Python pass-by-value or pass-by-reference?easy

    Strictly, neither. Python uses what's usually called pass by object reference (or "call by sharing"): the parameter becomes a new local name bound to the same object the caller passed. Nothing is copied, but the name itself belongs to the function.

    That produces two different outcomes:

    • Mutating the object (items.append(4)) is visible to the caller, because both names point at one list.
    • Rebinding the parameter (items = [0], or n += 1 on an int) only changes what the local name points to. The caller's variable is untouched.

    So it behaves like pass by value for immutable types (you can't change an int in place anyway) and like pass by reference for mutable ones, but it's one consistent rule. If a function must not affect the caller's data, copy it first or, better, return a new object. By convention, functions that mutate in place, like list.sort(), return None to make that obvious.

    def modify(items, n):
        items.append(4)      # mutates the caller's list
        items = [0]          # rebinds the local name only
        n += 1               # int is immutable: new object, local name only
    
    nums, count = [1, 2, 3], 10
    modify(nums, count)
    print(nums, count)       # [1, 2, 3, 4] 10
    What interviewers listen for
    • Parameters are new names bound to the same objects
    • Mutating an argument is visible to the caller
    • Rebinding a parameter never affects the caller
    • Immutable arguments therefore behave like pass-by-value
    • In-place mutators conventionally return None

    Likely follow-up: How would you stop a function from modifying a list you pass in?

  13. 13.Explain the difference between @staticmethod, @classmethod and @property, with a use case for each.mid

    All three change how a function defined in a class is accessed.

    • A regular method receives the instance as self.
    • @classmethod receives the class as cls, whether it's called on the class or on an instance. The main use is alternative constructors such as dict.fromkeys or datetime.fromtimestamp. Because it builds via cls, calling it on a subclass returns a subclass instance.
    • @staticmethod receives nothing implicitly. It's a plain function that lives in the class namespace because it belongs there logically. If it doesn't really relate to the class, a module-level function is often cleaner.
    • @property turns a method into a computed attribute read without parentheses (t.kelvin). Adding a @kelvin.setter lets you validate assignments. You can start with a plain attribute and add logic later without changing the public API, which is why Python code doesn't need Java-style getters and setters.

    Under the hood, all three are implemented as descriptors.

    class Temperature:
        def __init__(self, celsius):
            self.celsius = celsius
        @classmethod
        def from_fahrenheit(cls, f):     # alternative constructor: receives the class
            return cls((f - 32) * 5 / 9)
        @staticmethod
        def is_valid(c):                 # plain function in the class namespace
            return c >= -273.15
        @property
        def kelvin(self):                # computed attribute, read without ()
            return self.celsius + 273.15
    
    print(Temperature.from_fahrenheit(212).kelvin)   # 373.15
    What interviewers listen for
    • classmethod gets cls; used for alternative constructors
    • classmethod respects subclasses via cls
    • staticmethod gets no implicit first argument
    • property exposes a computed attribute without parentheses
    • Property setters add validation without API changes

    Likely follow-up: How do you make a read-only property? · Can a classmethod be called on an instance?

  14. 14.What is a context manager, and how does the with statement work under the hood?mid

    A context manager sets something up on entry to a with block and reliably tears it down on exit, even if the block raises. The protocol is two methods:

    • __enter__(self) runs first; its return value is bound to the as target.
    • __exit__(self, exc_type, exc, tb) always runs at the end. If the block raised, the exception details are passed in; otherwise all three are None. Returning a truthy value suppresses the exception, so normally you return None or False.

    It's essentially a reusable try/finally. Standard examples: open() closes the file, a threading.Lock is released, a database transaction commits or rolls back, decimal.localcontext() restores the previous precision.

    For simple cases @contextlib.contextmanager lets you write one as a generator instead of a class. One with can manage several at once (with open(a) as f, open(b) as g:), and async code uses async with with __aenter__/__aexit__.

    import time
    
    class Timer:
        def __enter__(self):
            self.start = time.perf_counter()
            return self                        # bound to the "as" target
        def __exit__(self, exc_type, exc, tb):
            self.elapsed = time.perf_counter() - self.start
            return False                       # don't swallow exceptions
    
    with Timer() as t:
        sum(range(10**6))
    print(f"took {t.elapsed:.3f}s")
    What interviewers listen for
    • __enter__ sets up; its return value binds to as
    • __exit__ always runs, receiving exception details
    • Truthy return from __exit__ suppresses the exception
    • A reusable, safer try/finally
    • contextlib.contextmanager builds one from a generator

    Likely follow-up: How would you write the same timer with contextlib? · What is contextlib.ExitStack for?

  15. 15.Compare threading, multiprocessing and asyncio. How do you decide which one to use?mid

    They're three ways to do more than one thing at a time:

    • threading: OS threads in one process, sharing memory. With the GIL only one runs Python code at a time, so threads suit I/O-bound work (network calls, disk, subprocesses) where they mostly wait. Sharing data is easy, but you need locks and race conditions are easy to create.
    • multiprocessing: separate processes, each with its own interpreter and GIL, giving true parallelism for CPU-bound work. The cost is higher startup time and memory, and data must be pickled to cross process boundaries.
    • asyncio: one thread running an event loop that switches between coroutines at each await. It handles thousands of concurrent I/O-bound tasks cheaply, but libraries must be async-aware, and one blocking call stalls the whole loop.

    concurrent.futures puts ThreadPoolExecutor and ProcessPoolExecutor behind one API. My rule of thumb: CPU-bound, use processes; many concurrent network requests, use asyncio; some blocking I/O or non-async libraries, use a thread pool.

    What interviewers listen for
    • Threads: shared memory, good for I/O-bound work
    • Processes: true parallelism for CPU-bound work
    • Processes cost startup, memory and pickling
    • asyncio: single-threaded event loop, cooperative await points
    • Blocking calls freeze the asyncio loop

    Likely follow-up: How do you call blocking code from asyncio? · Does free-threaded Python change this advice?

  16. 16.How is a Python dict implemented, and why does it preserve insertion order?hard

    A dict is a hash table. To store a key, Python calls hash(key) and uses the hash to pick a slot; on lookup it hashes again, finds the slot, and confirms the match with ==. Collisions are resolved with open addressing: CPython probes other slots in a pseudo-random sequence derived from the hash. That gives average O(1) get, set and delete, with a rare O(n) worst case.

    Since CPython 3.6 the layout is compact: a sparse array of small indices points into a dense array of (hash, key, value) entries stored in insertion order. That saved memory and made ordering a side effect, which Python 3.7 turned into a language guarantee. Iteration is fast because it just walks the dense array.

    The table is resized when it gets about two-thirds full, so inserts stay amortized O(1). Consequences: keys must be hashable with consistent __hash__ and __eq__, mutating a key's hash-relevant state breaks lookups, and adding or removing keys while iterating raises RuntimeError.

    What interviewers listen for
    • Hash table: hash picks the slot, == confirms
    • Open addressing with pseudo-random probing
    • Average O(1) lookup, insert and delete
    • Compact layout since 3.6; order guaranteed since 3.7
    • Keys must be hashable; no resizing during iteration

    Likely follow-up: Why can two objects with equal hashes both be keys? · Is OrderedDict still useful?

  17. 17.What is the difference between __str__ and __repr__?easy

    Both return a string representation, but for different audiences:

    • __repr__ is for developers: unambiguous, ideally valid Python that would recreate the object, like datetime.date(2024, 1, 15). It's what the REPL and debuggers show.
    • __str__ is for end users: readable, like 2024-01-15. It's what print(), str() and f-strings use.

    If a class defines only __repr__, str() falls back to it, so __repr__ is the one to always implement. The reverse isn't true: with only __str__, the REPL still shows the default <__main__.Point object at 0x...>.

    Two details interviewers like: containers use the repr of their elements, so print([d]) shows [datetime.date(2024, 1, 15)] rather than the friendly form. And in f-strings, !r forces the repr, so f"{name!r}" puts quotes around a string, which is handy in log and error messages.

    What interviewers listen for
    • __repr__: unambiguous, for developers, ideally recreates the object
    • __str__: readable, for end users
    • str() falls back to __repr__, not vice versa
    • Containers show the repr of their elements
    • !r in f-strings forces repr

    Likely follow-up: What does print() call on a list of objects?

  18. 18.How do you choose between a list, tuple, set and dict? What are the time complexities of their common operations?easy

    Choose by what you need to do with the data:

    • list: ordered, mutable, allows duplicates. Indexing and append are O(1), but x in lst, remove and insert(0, x) are O(n). The default for sequences you'll grow or reorder.
    • tuple: ordered and immutable. Use it for fixed records and as dict keys or set members.
    • set: unordered, unique, hashable elements only. Membership, add and remove are O(1) on average, plus fast union, intersection and difference. Use it for deduplication and "have I seen this?" checks.
    • dict: key-to-value mapping with O(1) average lookup, insertion order preserved, hashable keys. Use it for lookup by key, counting and grouping.

    The most common performance bug I look for is membership testing against a list inside a loop, which quietly turns O(n) code into O(n²). Converting the list to a set once fixes it. And remember that {} is an empty dict; an empty set is set().

    What interviewers listen for
    • List: ordered, mutable; in is O(n)
    • Tuple: immutable, hashable records
    • Set: unique elements, O(1) average membership
    • Dict: O(1) average key lookup, insertion-ordered
    • {} is a dict; use set() for empty set

    Likely follow-up: What is the complexity of list.pop(0), and what would you use instead?

  19. 19.Explain the LEGB rule. When do you need global or nonlocal?mid

    Python resolves a name by searching four scopes in order, LEGB: Local (the current function), Enclosing (outer functions, for nested functions), Global (the module's top level) and Built-in (len, print, Exception...).

    The key rule is that scope is decided at compile time: if a function assigns to a name anywhere in its body, that name is local for the whole function. That's why print(x) followed by x = 1 inside a function raises UnboundLocalError, even when a global x exists.

    To assign to an outer name you must declare it. global x rebinds the module-level name; nonlocal x rebinds the name in the nearest enclosing function. You need neither to read an outer variable or to mutate an outer object (items.append(1)), only to rebind the name.

    Heavy use of global is a code smell; returning values or using a class is usually cleaner. Also note that a class body is not an enclosing scope for its methods.

    x = "global"
    
    def outer():
        x = "enclosing"
        def inner():
            nonlocal x          # rebind outer's x instead of creating a local
            x = "changed"
        inner()
        return x
    
    print(outer())   # changed
    print(x)         # global
    What interviewers listen for
    • Lookup order: Local, Enclosing, Global, Built-in
    • Any assignment makes a name local for the whole function
    • Read-before-assign raises UnboundLocalError
    • global rebinds module names; nonlocal rebinds enclosing ones
    • Only rebinding needs a declaration, not reading or mutating

    Likely follow-up: Why is a class body not an enclosing scope for its methods?

  20. 20.What is a closure in Python? Why does creating lambdas in a loop often give surprising results?mid

    A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished. make_multiplier(2) returns a function that still sees k; CPython keeps it alive in a cell object, visible via double.__closure__.

    Closures capture variables, not values. Python uses late binding: the variable is looked up when the inner function is called. In the loop, all three lambdas close over the same i, and by the time they run the loop has finished with i == 2, so you get [2, 2, 2].

    Fixes, each capturing the value at creation time:

    • a default argument: lambda i=i: i
    • functools.partial(func, i)
    • a factory function that takes i as a parameter, like make_multiplier

    Closures underpin decorators, function factories and callbacks. To reassign a captured variable, such as a counter, the inner function needs nonlocal.

    def make_multiplier(k):
        return lambda x: x * k          # closes over k
    double = make_multiplier(2)
    print(double(5))                    # 10
    
    funcs = [lambda: i for i in range(3)]
    print([f() for f in funcs])         # [2, 2, 2]: i is looked up at call time
    
    funcs = [lambda i=i: i for i in range(3)]   # default arg binds the value now
    print([f() for f in funcs])         # [0, 1, 2]
    What interviewers listen for
    • Inner function keeps access to enclosing variables
    • Captures variables, not values: late binding
    • Loop lambdas all see the final loop value
    • Fix with default arg, partial, or a factory
    • nonlocal needed to reassign a captured variable

    Likely follow-up: How would you build a counter with a closure? · How are closures related to decorators?

  21. 21.How does try/except/else/finally work in Python? What goes in each block?easy
    • try: the code that might fail. Keep it as small as possible.
    • except SomeError as e: runs only if a matching exception was raised. Catch specific exceptions; a broad except Exception hides bugs, and a bare except: even catches KeyboardInterrupt and SystemExit.
    • else: runs only if the try block raised nothing. Code that depends on success goes here, so its own exceptions aren't accidentally caught by the handlers above.
    • finally: always runs, whether the block succeeded, raised, or exited via return or break. Use it for cleanup, although a with statement is usually nicer.

    Handlers are checked top to bottom, so put subclasses before their base classes. Inside an except, a bare raise re-raises the current exception with its original traceback.

    A gotcha: a return inside finally overrides the try block's return value and silently swallows any exception in flight. Python 3.14 now emits a SyntaxWarning for return, break or continue in a finally block (PEP 765).

    What interviewers listen for
    • except catches specific exceptions only
    • else runs only when no exception occurred
    • finally always runs, even after return
    • Order handlers from most specific to most general
    • Never return from finally: it swallows exceptions

    Likely follow-up: Why not just put the else code at the end of the try block? · What is the difference between raise and raise e?

  22. 22.What is a lambda function in Python, and when should (or shouldn't) you use one?easy

    A lambda is an anonymous function written as a single expression: lambda x, y: x + y. The expression's value is returned implicitly. It can take defaults, *args and **kwargs like a normal function, but it can't contain statements (no = assignments, no return, no try), and in tracebacks its name is just <lambda>. Otherwise it's an ordinary function object with the same closure and late-binding rules.

    Good uses are short throwaway callbacks, especially key functions: sorted(users, key=lambda u: u.age), max(pairs, key=lambda p: p[1]), or a defaultdict(lambda: "n/a") factory.

    When not to use one:

    • PEP 8 says not to assign a lambda to a name (square = lambda x: x * x). Write a def, which gets a real name and can have a docstring.
    • If the logic needs more than one simple expression, use def.
    • For common key functions, operator.itemgetter and operator.attrgetter are often clearer.
    What interviewers listen for
    • Anonymous, single expression, implicit return
    • No statements or assignments inside
    • Ideal for short key functions and callbacks
    • PEP 8: use def instead of naming a lambda
    • Same closure and late-binding rules as def

    Likely follow-up: What is operator.itemgetter and when would you prefer it?

  23. 23.How do map, filter and reduce work, and why are comprehensions often preferred?easy

    map(func, iterable) applies a function to every item, filter(pred, iterable) keeps the items where the predicate is truthy, and functools.reduce(func, iterable, initial) folds all items into one value. In Python 3, map and filter return lazy iterators, not lists, so wrap them in list() if you need one, and remember they're single-pass. reduce was moved out of the built-ins into functools.

    Comprehensions are usually preferred because they read left to right and combine mapping and filtering in one place:

    • list(map(lambda n: n * n, nums)) becomes [n * n for n in nums]
    • list(filter(lambda n: n > 0, nums)) becomes [n for n in nums if n > 0]

    map is still neat when you already have a named function, as in map(int, line.split()), and filter(None, items) drops falsy values. For reduce, a dedicated built-in is usually clearer: sum, math.prod, max, any, all or "".join.

    What interviewers listen for
    • map transforms, filter selects, reduce folds
    • map/filter return lazy iterators in Python 3
    • reduce lives in functools
    • Comprehensions are more readable, especially with lambdas
    • Prefer sum, math.prod, any, all over reduce

    Likely follow-up: What does filter(None, items) return?

  24. 24.What is the difference between an iterable and an iterator? Explain the iterator protocol.mid

    An iterable is anything you can loop over: it has an __iter__ method that returns an iterator (or supports the older __getitem__ sequence protocol). An iterator is the object that actually produces values: its __next__ returns the next item or raises StopIteration when done, and its __iter__ returns itself.

    A for loop is essentially it = iter(obj) followed by repeated next(it) until StopIteration.

    The distinction matters in practice. A list is iterable but not an iterator, so every for loop gets a fresh iterator and you can loop over it many times. Iterators such as file objects, generators, map and zip are single-pass and stay exhausted. Countdown above is its own iterator, which is why the second list(c) is empty.

    To make a reusable iterable, have __iter__ return a new iterator each time. The easiest way is to write __iter__ as a generator function with yield.

    class Countdown:
        def __init__(self, start):
            self.current = start
        def __iter__(self):
            return self                  # an iterator returns itself
        def __next__(self):
            if self.current <= 0:
                raise StopIteration      # tells the for-loop to stop
            self.current -= 1
            return self.current + 1
    
    c = Countdown(3)
    print(list(c), list(c))              # [3, 2, 1] []
    What interviewers listen for
    • Iterable: __iter__ returns an iterator
    • Iterator: __next__ plus __iter__ returning self
    • StopIteration signals the end
    • for calls iter() once, then next() repeatedly
    • Iterators are single-pass; containers are re-iterable

    Likely follow-up: How would you make Countdown re-iterable? · What does iter(callable, sentinel) do?

  25. 25.How does multiple inheritance work in Python? Explain the MRO and what super() actually calls.hard

    Attribute lookup follows the class's Method Resolution Order (MRO), computed with the C3 linearization algorithm. It lists the class, then its bases, with two guarantees: every class appears before its own parents, and the left-to-right order of bases is preserved. Inspect it with C.__mro__ or C.mro().

    super() doesn't mean "my parent". It means the next class after the current one in the MRO of the instance's type. In the diamond above, A's super().greet() calls B.greet, not Base.greet, because the object is a C. That's what makes cooperative multiple inheritance work: each class calls super() once and Base runs exactly once.

    For that to work, every class in the chain must call super() and accept extra arguments (typically **kwargs) to pass along. If C3 can't find a consistent order, such as combining X(A, B) with Y(B, A), class creation fails with a TypeError. In practice, prefer small, focused mixins over deep diamonds.

    class Base:
        def greet(self): return ["Base"]
    
    class A(Base):
        def greet(self): return ["A"] + super().greet()
    
    class B(Base):
        def greet(self): return ["B"] + super().greet()
    
    class C(A, B):
        def greet(self): return ["C"] + super().greet()
    
    print(C().greet())                       # ['C', 'A', 'B', 'Base']
    print([k.__name__ for k in C.__mro__])   # ['C', 'A', 'B', 'Base', 'object']
    What interviewers listen for
    • MRO computed by C3 linearization
    • Children before parents; base order preserved
    • super() means next in the instance type's MRO
    • Cooperative classes all call super(), pass **kwargs
    • Inconsistent hierarchies raise TypeError at class creation

    Likely follow-up: How does the diamond problem play out in Python? · What is a mixin?

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

    CPython uses two mechanisms:

    • Reference counting is the primary one. Every object tracks how many references point to it, and when the count drops to zero the object is freed immediately. That's why files are usually closed promptly in CPython.
    • A cyclic garbage collector handles what refcounting can't: reference cycles, like two objects pointing at each other or an object that refers to itself. It periodically finds groups of container objects reachable only from each other and frees them. It's generational: new objects are checked often, survivors less often. (CPython 3.14.0 to 3.14.4 briefly shipped an incremental collector; 3.14.5 reverted it.) The gc module can trigger a collection (gc.collect()), tune thresholds, or disable it.

    Related points: small objects come from CPython's own allocator (pymalloc), so freed memory is often reused by the process rather than returned to the OS. weakref lets you refer to an object without keeping it alive, which helps with caches and cycles. All of this is CPython-specific: PyPy, for example, uses a tracing GC instead of reference counting.

    What interviewers listen for
    • Reference counting frees objects at zero references
    • Cyclic GC collects unreachable reference cycles
    • GC is generational; gc module controls it
    • weakref references without keeping objects alive
    • Details are CPython-specific (PyPy differs)

    Likely follow-up: Why might a long-running Python process not shrink after freeing objects? · What problems can __del__ cause?

  27. 27.How do classes and inheritance work in Python? What are self and __init__?easy

    A class bundles data (attributes) and behavior (methods). Calling the class, Dog("Rex"), creates an instance: __new__ allocates it, then __init__ initializes it. So __init__ is an initializer rather than a true constructor, and it must return None.

    self is the instance, passed automatically as the first argument when you call a method on it: rex.speak() is really Dog.speak(rex). The name is a convention, not a keyword.

    Attributes set on self are instance attributes; names defined in the class body are class attributes, shared by every instance. A classic bug is a mutable class attribute like tricks = [] that all instances end up sharing.

    With class Dog(Animal):, Dog inherits Animal's methods and can override them. If you override __init__, the parent's version isn't called automatically, so call super().__init__(...). Use isinstance and issubclass for type checks. Python has no truly private members: _name is a convention, and __name triggers name mangling to avoid clashes in subclasses.

    What interviewers listen for
    • __init__ initializes an instance __new__ created
    • self is the instance, passed implicitly
    • Class attributes are shared; instance attributes are per object
    • Overriding __init__ needs explicit super().__init__()
    • _x is convention; __x is name-mangled

    Likely follow-up: What happens if you define a list as a class attribute? · What is name mangling?

  28. 28.Is Python an interpreted or a compiled language?easy

    Both, in a sense. The reference implementation, CPython, first compiles source code to bytecode, a lower-level, platform-independent instruction set, and then interprets that bytecode in a virtual machine loop. The compile step is automatic and fast, which is why Python feels purely interpreted.

    For imported modules, CPython caches the bytecode as .pyc files in __pycache__, so later imports skip compilation if the source hasn't changed. The script you run directly isn't cached. You can inspect bytecode with the dis module, e.g. dis.dis(func).

    Other implementations take different approaches: PyPy uses a just-in-time compiler and is often much faster for long-running pure-Python code, and Cython compiles Python-like code into C extensions. Recent CPython versions also ship an experimental JIT that is off by default, so it's not something to rely on yet.

    The key point: "Python" is a language specification, and CPython is its most common implementation.

    What interviewers listen for
    • CPython compiles source to bytecode first
    • A virtual machine then interprets the bytecode
    • Imported modules are cached as .pyc in __pycache__
    • PyPy uses a JIT; CPython's JIT is experimental
    • Language versus implementation distinction

    Likely follow-up: What does the dis module show you?

  29. 29.What does if __name__ == "__main__": do, and why is it used?easy

    Every module has a __name__ attribute. When a module is imported, __name__ is its module name ("greet"). When you run a file directly, with python greet.py or python -m greet, Python sets __name__ to "__main__" instead.

    So the guard lets one file be both a reusable module and a runnable script: the code under it runs only when the file is executed directly, not as a side effect of being imported. Without it, importing the module (from a test, say) would kick off the script's work.

    Good practice is to put the logic in a main() function and keep the guard to a single call, so tests can call main() directly and nothing leaks into module globals.

    It's also essential with multiprocessing under the spawn start method (the default on Windows and macOS), because each child process re-imports the main module, and unguarded top-level code would run again in every child. For packages, a __main__.py file is what makes python -m package work.

    # greet.py
    def main():
        print("running as a script")
    
    if __name__ == "__main__":    # True for "python greet.py" or "python -m greet"
        main()                    # skipped on "import greet" (__name__ == "greet")
    What interviewers listen for
    • __name__ is "__main__" when run directly
    • Imported modules get their module name instead
    • Lets a file be both importable and runnable
    • Keep the guard to a main() call
    • Required with multiprocessing spawn start method

    Likely follow-up: What is __main__.py used for in a package?

  30. 30.What are dunder (magic) methods? Give examples of how they let your classes work with built-in syntax.mid

    Dunder ("double underscore") methods are hooks into Python's data model: the interpreter calls them implicitly so your objects work with built-in syntax and functions. You rarely call them yourself.

    • Creation and display: __new__, __init__, __repr__, __str__
    • Comparison and hashing: __eq__, __lt__, __hash__
    • Arithmetic: __add__, __mul__, and reflected versions like __radd__ for 2 + obj
    • Containers: __len__, __getitem__, __contains__ (for in), __iter__
    • Truthiness and calls: __bool__ (falls back to __len__), __call__
    • Context managers: __enter__, __exit__

    Just __len__ and __getitem__ already give you len(), indexing, iteration and in. When an operator method can't handle the other operand's type, it should return NotImplemented (not raise), so Python can try the reflected method on the other object. One subtlety: implicit calls look up dunders on the type, not the instance, so setting obj.__len__ = ... doesn't change len(obj).

    What interviewers listen for
    • Hooks the interpreter calls for operators and built-ins
    • Examples: __len__, __getitem__, __add__, __call__
    • Return NotImplemented for unsupported operand types
    • __bool__ falls back to __len__
    • Special methods are looked up on the type

    Likely follow-up: What is the difference between __getattr__ and __getattribute__? · When is __radd__ called?

  31. 31.How do __eq__ and __hash__ relate? What happens if you define __eq__ but not __hash__?hard

    By default, objects compare and hash by identity: two instances are equal only if they're the same object. Once you define __eq__ to compare by value, you must keep the hash contract: if a == b, then hash(a) == hash(b). Dicts and sets depend on it, because they find the bucket by hash first and only then check ==.

    Python enforces part of this. If a class defines __eq__ but not __hash__, its __hash__ is set to None, so instances become unhashable and adding one to a set raises TypeError. That's deliberate: the inherited identity-based hash would break the contract.

    Best practice is to hash a tuple of exactly the fields __eq__ compares, and only make objects hashable if those fields never change. Mutating a key after it's in a dict or set leaves it in the wrong bucket, so lookups silently fail. Return NotImplemented for unknown types so Python can try the other operand. Dataclasses automate all this: @dataclass(frozen=True) generates a matching __eq__ and __hash__.

    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))    # hash exactly the fields __eq__ uses
    
    print(Point(1, 2) == Point(1, 2))        # True
    print(len({Point(1, 2), Point(1, 2)}))   # 1
    What interviewers listen for
    • Contract: equal objects must have equal hashes
    • Defining only __eq__ sets __hash__ to None
    • Hash the same immutable fields __eq__ compares
    • Mutating a hashed key breaks dict and set lookups
    • Return NotImplemented for unsupported types

    Likely follow-up: Why is it fine for unequal objects to share a hash? · How does @dataclass decide whether to add __hash__?

  32. 32.How do async/await and the asyncio event loop work? What is the most common mistake people make with them?hard

    async def defines a coroutine function. Calling it runs nothing; it returns a coroutine object that must be awaited or scheduled as a task. await suspends the current coroutine until the awaited operation finishes, handing control back to the event loop.

    The event loop is a single-threaded scheduler. It runs a task until that task awaits something that isn't ready (a socket read, a sleep), then switches to another task that can make progress. This is cooperative multitasking: thousands of I/O-bound tasks overlap in one thread, like the two fetches above finishing in about one second total.

    Key tools: asyncio.run() as the entry point, asyncio.create_task() to start work concurrently, asyncio.gather() or asyncio.TaskGroup (3.11+) to wait for several tasks, and asyncio.timeout() (3.11+) for deadlines.

    The most common mistake is blocking the loop: time.sleep(), a synchronous HTTP client or heavy CPU work inside a coroutine freezes every task. Use async libraries, asyncio.to_thread() for blocking calls, or a process pool for CPU work. Forgetting await is the other classic bug.

    import asyncio
    
    async def fetch(name, delay):
        await asyncio.sleep(delay)          # suspends; the loop runs other tasks
        return f"{name} done"
    
    async def main():
        results = await asyncio.gather(fetch("a", 1), fetch("b", 1))
        print(results)                      # ['a done', 'b done'] after ~1s, not 2s
    
    asyncio.run(main())                     # creates the loop, runs main, closes it
    What interviewers listen for
    • Calling a coroutine function only creates a coroutine object
    • await yields control back to the event loop
    • Single thread, cooperative multitasking for I/O
    • gather/TaskGroup run tasks concurrently
    • Blocking calls freeze the loop: use to_thread

    Likely follow-up: What is the difference between gather and TaskGroup? · How do you cancel a task, and what does it receive?

  33. 33.What does @dataclass do, and what options does it offer?mid

    @dataclass generates boilerplate from a class's type-annotated fields: __init__, a readable __repr__ and a field-by-field __eq__. Options add more:

    • order=True generates <, <=, > and >=, comparing fields as a tuple in declaration order.
    • frozen=True makes instances immutable (assignment raises FrozenInstanceError) and, with the default eq=True, also hashable.
    • slots=True and kw_only=True (3.10+) add __slots__ and keyword-only fields.

    field() customizes individual fields: default_factory for mutable defaults (a bare [] raises ValueError), and compare=False or repr=False to exclude a field. __post_init__ runs after the generated __init__ for validation or derived values.

    Annotations are required to declare fields but aren't enforced at runtime. Compared with alternatives: namedtuple is immutable and tuple-like, while third-party libraries like Pydantic and attrs add runtime validation, conversion and more. For plain data holders in the standard library, dataclasses are the default choice.

    from dataclasses import dataclass, field
    
    @dataclass(order=True)
    class Version:
        major: int
        minor: int = 0
        tags: list[str] = field(default_factory=list, compare=False)
    
    v = Version(1, 2)
    print(v)                      # Version(major=1, minor=2, tags=[])
    print(v == Version(1, 2))     # True: generated __eq__ compares fields
    print(Version(1, 10) > v)     # True: order=True compares (major, minor)
    What interviewers listen for
    • Generates __init__, __repr__, __eq__ from annotations
    • frozen=True gives immutability and hashability
    • order=True adds comparison operators
    • field(default_factory=...) for mutable defaults
    • Type hints are not validated at runtime

    Likely follow-up: What is __post_init__ used for? · When would you pick Pydantic over a dataclass?

  34. 34.How do you define custom exceptions, and what is exception chaining (raise ... from ...)?mid

    Subclass Exception, not BaseException, which is reserved for things like KeyboardInterrupt and SystemExit. A library typically defines one base class plus specific subclasses:

    • class PaymentError(Exception) as the base, so callers can catch everything from your library in one place
    • class CardDeclined(PaymentError) with extra attributes such as self.code, set in __init__ after calling super().__init__(message)

    Use an Error suffix, as PEP 8 suggests, and expose attributes rather than making callers parse the message.

    When translating a low-level error, use exception chaining: raise PaymentError("gateway timed out") from err. That sets __cause__, and the traceback shows both errors. If you raise inside an except without from, Python still records the original as __context__ ("During handling of the above exception, another exception occurred"). raise ... from None suppresses the original when it's just noise.

    Newer tools: err.add_note(...) (3.11+) attaches extra context, and ExceptionGroup with except* (3.11+) handles several errors at once, such as failures from an asyncio.TaskGroup.

    What interviewers listen for
    • Subclass Exception, not BaseException
    • One library base class, specific subclasses beneath
    • Carry data as attributes, not just messages
    • raise X from err sets __cause__
    • from None hides the original exception

    Likely follow-up: What is the difference between __cause__ and __context__? · When would you use except*?

  35. 35.How do you write a decorator that takes arguments, like @retry(times=3)?hard

    It needs one more level of nesting. @repeat(times=3) first calls repeat(times=3), which is a decorator factory: it returns the real decorator, which is then applied to the function. So there are three layers:

    • the factory takes the configuration (times)
    • the decorator takes the function (func)
    • the wrapper takes each call's arguments (*args, **kwargs)

    Each layer is a closure over the one outside it, so wrapper sees both times and func. @wraps(func) still goes on the innermost function.

    Real-world examples: a @retry(times=5, exceptions=(ConnectionError,)), @app.route("/users") in Flask, @lru_cache(maxsize=128) and @pytest.mark.parametrize(...).

    A common gotcha is writing @repeat without parentheses: the function gets passed in as times and things fail confusingly later. A decorator meant to work both ways can check whether its first argument is callable, as lru_cache does. For stateful decorators, a class with __init__ and __call__ is an alternative to nested functions.

    from functools import wraps
    def repeat(times):
        def decorator(func):                 # receives the function
            @wraps(func)
            def wrapper(*args, **kwargs):    # receives the call's arguments
                return [func(*args, **kwargs) for _ in range(times)]
            return wrapper
        return decorator
    
    @repeat(times=3)            # repeat(times=3) runs first, returns decorator
    def greet(name):
        return f"hi {name}"
    
    print(greet("ana"), greet.__name__)   # ['hi ana', 'hi ana', 'hi ana'] greet
    What interviewers listen for
    • Three layers: factory, decorator, wrapper
    • @dec(args) calls the factory first
    • Inner layers close over outer variables
    • @wraps(func) goes on the innermost wrapper
    • Forgetting parentheses passes the function as the argument

    Likely follow-up: How would you implement @retry with exponential backoff? · How do you make a decorator usable with and without parentheses?

  36. 36.Strings are immutable in Python. What does that mean in practice, and why is "".join() preferred over += in a loop?easy

    Immutable means no operation changes a string in place: methods that seem to modify one (replace, upper, strip) return a new string, and s[0] = "x" raises TypeError. The upside is that strings are hashable (usable as dict keys), safe to share between threads, and CPython can intern and reuse them.

    The cost shows up when building a string piece by piece. s += piece in a loop can create a new string and copy everything so far on each iteration, so the total work can grow quadratically. CPython has an optimization that sometimes resizes in place, but PEP 8 explicitly says not to rely on it: it's fragile and absent in other implementations.

    The idiom is to collect pieces in a list (or generator) and call "".join(pieces) once. join computes the total length, allocates once and copies each piece once, so it's linear. For formatting a few values, use f-strings; for many incremental writes, io.StringIO also works. Note that join requires strings: ", ".join(map(str, numbers)).

    What interviewers listen for
    • String methods return new strings
    • Item assignment raises TypeError
    • += in a loop can be quadratic
    • "".join() allocates once: linear time
    • PEP 8: don't rely on CPython's += optimization

    Likely follow-up: How would you reverse a string? · What does "".join([1, 2]) do?

  37. 37.Explain Python slicing syntax. How do you reverse a list or string, and what happens with out-of-range indices?easy

    Slicing is seq[start:stop:step]. start is inclusive and stop is exclusive; all three are optional, defaulting to the beginning, the end and a step of 1. Negative indices count from the end.

    • s[:3] is the first three items, s[-3:] the last three, s[1:-1] drops both ends
    • s[::2] takes every second item, s[::-1] is a reversed copy
    • lst[:] is a shallow copy of a list

    Useful properties: slicing never raises IndexError; it clips to the bounds, so "abc"[5:] is "" (plain indexing like "abc"[5] does raise). It works on any sequence (str, list, tuple, range, bytes) and returns the same type. List slices are copies, not views, unlike NumPy array slices.

    Slice assignment can replace, insert or delete ranges and change the length: lst[1:3] = [7, 8, 9], lst[:0] = [0] to prepend, del lst[::2]. A slice(start, stop, step) object lets you name and reuse a slice, and it's what custom classes receive in __getitem__.

    What interviewers listen for
    • start inclusive, stop exclusive, optional step
    • Negative indices count from the end
    • [::-1] reverses; [:] shallow-copies
    • Out-of-range slices clip instead of raising
    • Slice assignment can resize a list

    Likely follow-up: Is lst[:] a deep or shallow copy? · How does a class support slicing in __getitem__?

  38. 38.What is the difference between a module and a package, and how does Python find what you import?easy

    A module is a single .py file; its top-level names become attributes of the module object. A package is a directory of modules. A regular package has an __init__.py, which runs on first import and can expose a curated API. Without one, the directory is a namespace package (PEP 420), which can even be split across several locations.

    On import x, Python first checks the sys.modules cache, so a module's top-level code runs only once per process. Otherwise it searches sys.path: the script's directory, PYTHONPATH, the standard library and site-packages.

    Other points interviewers look for:

    • Absolute imports (from app.models import User) are preferred; relative ones (from .models import User) only work inside packages.
    • __all__ controls what from module import * exports.
    • Circular imports often fail because one module is only partially initialized. Fix them by restructuring, or by moving an import into the function that needs it.
    • Run package modules with python -m package.module so imports resolve correctly.
    What interviewers listen for
    • Module is a file; package is a directory of modules
    • __init__.py makes a regular package
    • Imports are cached in sys.modules; code runs once
    • Search order comes from sys.path
    • Circular imports see partially initialized modules

    Likely follow-up: How would you fix a circular import? · What is a namespace package?

  39. 39.What is a virtual environment, and how do you manage dependencies in a Python project?mid

    A virtual environment is an isolated directory with its own interpreter link and site-packages, so each project gets its own dependency versions without clashing with other projects or the system Python. Create one with python -m venv .venv, activate it (source .venv/bin/activate on macOS and Linux), then install with python -m pip install .... Running pip via python -m guarantees you use the pip of the right interpreter.

    Many OS-managed Pythons now refuse a global pip install with an "externally managed environment" error (PEP 668), which pushes you toward venvs anyway.

    For dependency management:

    • Declare direct dependencies in pyproject.toml (the modern standard) or requirements.txt, with sensible version ranges.
    • Pin the full resolved set, including transitive dependencies, for reproducible builds: pip freeze, pip-tools, or the lock file of tools like Poetry or uv.
    • Don't commit the venv itself; recreate it from the lock file, and have CI and Docker images install from it too.
    What interviewers listen for
    • Isolated interpreter and site-packages per project
    • python -m venv .venv, then activate
    • Use python -m pip to target the right interpreter
    • Declare in pyproject.toml, pin with a lock file
    • Never commit the venv; recreate it

    Likely follow-up: What is the difference between requirements.txt and pyproject.toml? · Why pin transitive dependencies?

  40. 40.Which classes in the collections module do you use, and what problems do they solve?mid

    collections provides specialized containers that replace a lot of hand-written code:

    • Counter: a dict subclass for counting hashable items, with most_common(n) and arithmetic between counters. Missing keys return 0.
    • defaultdict: calls a factory (list, int, set) to create the value for a missing key, perfect for grouping and counting.
    • deque: a double-ended queue with O(1) appends and pops at both ends. Use it for queues and BFS instead of list.pop(0), which is O(n). maxlen makes it a bounded buffer.
    • namedtuple: a tuple subclass with named fields, lightweight and immutable. typing.NamedTuple is the class-syntax version with type hints.
    • OrderedDict: plain dicts keep insertion order now, but OrderedDict still offers move_to_end() and popitem(last=False) (handy for LRU caches), and its equality check is order-sensitive.
    • ChainMap: layered lookups across several dicts, such as CLI args over environment variables over defaults.
    from collections import Counter, defaultdict, deque
    
    print(Counter("mississippi").most_common(2))   # [('i', 4), ('s', 4)]
    
    groups = defaultdict(list)
    for word in ["apple", "avocado", "banana"]:
        groups[word[0]].append(word)               # no KeyError on a new key
    print(dict(groups))       # {'a': ['apple', 'avocado'], 'b': ['banana']}
    
    recent = deque([1, 2, 3], maxlen=3)
    recent.append(4)          # O(1) at both ends; the oldest item falls off
    print(recent)             # deque([2, 3, 4], maxlen=3)
    What interviewers listen for
    • Counter for counting and most_common
    • defaultdict avoids missing-key boilerplate
    • deque has O(1) operations at both ends
    • namedtuple: immutable records with named fields
    • OrderedDict still useful for move_to_end

    Likely follow-up: Why is list.pop(0) slow? · How would you build an LRU cache with OrderedDict?

  41. 41.What do functools.lru_cache and functools.partial do, and when would you use them?mid

    lru_cache memoizes a function: results are stored keyed by the arguments, so repeated calls with the same arguments return instantly. maxsize bounds the cache (default 128) and evicts the least recently used entry; maxsize=None, or functools.cache (3.9+), makes it unbounded. cache_info() reports hits and misses, and cache_clear() resets it. The classic use is turning an exponential recursive solution, like naive Fibonacci or a DP problem, into a linear one.

    Caveats: arguments must be hashable (passing a list raises TypeError), the function should be pure, and on methods the cache keeps references to self, which can keep instances alive. functools.cached_property is often better for per-instance values.

    partial pre-fills some arguments and returns a new callable: parse_bin = partial(int, base=2), then parse_bin("1010") returns 10. It's handy for callbacks and map, and unlike a lambda it can be pickled (if the wrapped function can), which matters for multiprocessing.

    Other useful members: wraps, reduce, total_ordering and singledispatch.

    What interviewers listen for
    • lru_cache memoizes by arguments; default maxsize 128
    • cache (3.9+) is the unbounded version
    • Arguments must be hashable; function should be pure
    • Method caches can keep instances alive
    • partial pre-binds arguments, and is picklable

    Likely follow-up: How would you cache a function that takes a list? · What does typed=True do in lru_cache?

  42. 42.How does sorting work in Python? Explain key, the difference between sorted() and list.sort(), and sort stability.mid

    sorted(iterable) returns a new list and accepts any iterable; list.sort() sorts a list in place and returns None, so x = lst.sort() is a classic bug. Both take key and reverse.

    key is a function called once per element to compute the value that gets compared: len, str.lower, itemgetter(1) or a lambda. That's faster and simpler than old-style comparison functions; if you really need one, functools.cmp_to_key converts it.

    Python's sort is stable: elements with equal keys keep their original relative order. That enables two techniques:

    • sort by a tuple key for multiple criteria, negating numbers for descending order: key=lambda p: (-p[1], p[0])
    • sort in multiple passes, least significant key first, which also handles mixed directions on non-numeric fields

    The algorithm is derived from Timsort, a merge/insertion hybrid: O(n log n) in the worst case and close to O(n) on partly ordered data. Comparing incompatible types, like None with int, raises TypeError in Python 3.

    from operator import itemgetter
    people = [("ana", 30), ("bo", 25), ("cy", 30), ("di", 25)]
    
    print(sorted(people, key=itemgetter(1)))
    # [('bo', 25), ('di', 25), ('ana', 30), ('cy', 30)]  ties keep input order
    
    print(sorted(people, key=lambda p: (-p[1], p[0])))   # age desc, name asc
    # [('ana', 30), ('cy', 30), ('bo', 25), ('di', 25)]
    
    people.sort(key=itemgetter(0), reverse=True)   # in place, returns None
    print(people[0])                               # ('di', 25)
    What interviewers listen for
    • sorted() returns new list; .sort() is in place, returns None
    • key is computed once per element
    • Stable: equal keys keep original order
    • Tuple keys or multiple passes for multi-field sorts
    • O(n log n), fast on partly sorted data

    Likely follow-up: How would you sort by one field descending and a string field ascending? · What does cmp_to_key do?

  43. 43.What are type hints in Python, and are they enforced at runtime?mid

    Type hints annotate the expected types of variables, parameters and return values: def greet(name: str, times: int = 1) -> str. They are not enforced at runtime; greet(42) still runs. Their value comes from static checkers like mypy and Pyright, IDE completion and refactoring, and libraries that read them on purpose, such as dataclasses, Pydantic and FastAPI.

    The syntax has improved a lot:

    • Built-in generics like list[int] and dict[str, float] (3.9+) instead of typing.List
    • int | None unions (3.10+) instead of Optional[int]
    • Type parameter syntax (3.12+): def first[T](items: list[T]) -> T, class Box[T]: and the type Alias = ... statement
    • In 3.14, annotations are evaluated lazily (PEP 649), so forward references work without quotes or from __future__ import annotations

    Other everyday tools: Callable, Literal, TypeVar, Protocol for structural typing, TypedDict for dict shapes, Any as an escape hatch, and typing.cast. I add hints gradually, starting with public function signatures.

    What interviewers listen for
    • Hints are not enforced at runtime
    • Checked statically by mypy or Pyright
    • Modern syntax: list[int], X | None
    • 3.12+ generic syntax def f[T](...)
    • Adopt gradually, starting with public APIs

    Likely follow-up: What is the difference between Any and object? · How would you type a decorator?

  44. 44.What does __slots__ do, and what are its trade-offs?hard

    Normally each instance stores its attributes in a per-instance __dict__, which is flexible but costs memory. Declaring __slots__ fixes the set of attribute names up front, so instances store them in a compact layout and get no __dict__ (and no __weakref__ unless you list it).

    Benefits:

    • Memory: noticeably smaller instances, which matters with millions of small objects
    • Slightly faster attribute access
    • Typos fail loudly: assigning an undeclared attribute raises AttributeError

    Trade-offs and gotchas:

    • No dynamic attributes, and tools that expect __dict__ may break
    • A subclass that doesn't define __slots__ gets a __dict__ back, losing the savings
    • A class attribute can't share a slot's name (a default like x = 0 raises ValueError)
    • Inheriting from two classes that both have non-empty slots raises a layout-conflict TypeError

    It's an optimization for many small, fixed-shape objects, not a default. @dataclass(slots=True) (3.10+) generates it for you.

    class Point:
        __slots__ = ("x", "y")          # fixed attributes, no per-instance __dict__
        def __init__(self, x, y):
            self.x, self.y = x, y
    
    p = Point(1, 2)
    p.x = 10                            # fine
    p.z = 3   # AttributeError: 'Point' object has no attribute 'z' and no __dict__ ...
    What interviewers listen for
    • Replaces per-instance __dict__ with fixed slots
    • Saves memory, slightly faster attribute access
    • Undeclared attributes raise AttributeError
    • Subclasses need their own __slots__ to keep savings
    • No weakrefs unless __weakref__ is listed

    Likely follow-up: How does __slots__ interact with inheritance? · How would you measure the memory saving?

  45. 45.How do you write a context manager with contextlib, and what other helpers does the module provide?mid

    contextlib saves you from writing a full class with __enter__ and __exit__.

    @contextmanager turns a generator into a context manager: code before yield is the setup, the yielded value is bound to as, and code after it is the teardown. If the with block raises, the exception is re-raised at the yield, so wrap it in try/finally or the cleanup is skipped. (The standard library has had contextlib.chdir since 3.11; cd above is just an illustration.)

    Other helpers worth knowing:

    • suppress(*exceptions): ignore specific exceptions, cleaner than try/except: pass
    • ExitStack: manage a dynamic number of context managers, like a list of open files
    • closing(obj): calls obj.close() on exit
    • nullcontext(): a do-nothing stand-in when a manager is optional
    • redirect_stdout: temporarily capture printed output
    • asynccontextmanager: the async with version

    The decorator form suits simple setup/teardown pairs; a class is better when the manager carries state.

    from contextlib import contextmanager, suppress
    import os
    
    @contextmanager
    def cd(path):
        old = os.getcwd()
        os.chdir(path)                # setup: runs on entering the with-block
        try:
            yield                     # the with-block body runs here
        finally:
            os.chdir(old)             # teardown: runs even if the block raised
    
    with suppress(FileNotFoundError):
        os.remove("maybe-missing.tmp")
    What interviewers listen for
    • @contextmanager: setup, yield, teardown
    • Wrap yield in try/finally for guaranteed cleanup
    • suppress ignores specific exceptions
    • ExitStack handles a variable number of managers
    • nullcontext, closing, redirect_stdout for special cases

    Likely follow-up: What happens if the generator yields twice? · How would you open a variable number of files safely?

  46. 46.What does yield from do, and how is it different from looping over a sub-generator and yielding each item?hard

    yield from iterable delegates to another iterable or generator: it yields every value the sub-iterator produces, as if you'd written for x in sub: yield x. The simplest uses are chaining and flattening, as in the recursive flatten above.

    With generators it does more than the loop:

    • The sub-generator's return value becomes the value of the yield from expression. A generator's return x travels in StopIteration.value, and yield from unpacks it, so outer receives "done".
    • It forwards send(), throw() and close() to the sub-generator, so the caller effectively talks to the inner generator directly. A hand-written loop silently drops those.

    Historically, this two-way delegation was the mechanism behind generator-based coroutines before async/await arrived in 3.5. Today it's mostly used for composing generators and recursive traversals. One caution: recursive yield from still grows the call stack, so very deep structures can hit the recursion limit.

    def flatten(items):
        for item in items:
            if isinstance(item, list):
                yield from flatten(item)   # delegate to a sub-generator
            else:
                yield item
    print(list(flatten([1, [2, [3, 4]], 5])))   # [1, 2, 3, 4, 5]
    def inner():
        yield 1
        return "done"                      # becomes StopIteration.value
    def outer():
        result = yield from inner()        # receives "done"
        yield result
    print(list(outer()))                   # [1, 'done']
    What interviewers listen for
    • Delegates iteration to a sub-iterator
    • Captures the sub-generator's return value
    • Forwards send, throw and close
    • Was the basis of pre-async coroutines
    • Recursive delegation still uses stack depth

    Likely follow-up: How would you get a generator's return value without yield from?

  47. 47.Why does a is b return True for 256 but False for 257 in some cases?hard

    It's a CPython implementation detail. At startup CPython pre-allocates the integers from -5 to 256 and reuses them, so anything that produces one of those values gets the same object. Outside that range each computation typically creates a new object, so is returns False even though the values are equal.

    Results can still look inconsistent because of compile-time constants: equal literals in the same code object are usually stored once. So a = 257 and b = 257 in one script or function often give a is b as True, while entering the same lines one by one in the REPL gives False. Strings behave similarly: identifier-like strings and some constants are interned, others aren't, and sys.intern() interns explicitly.

    None of this is guaranteed by the language; it varies between implementations and versions, so code must never depend on it. That's why Python 3.8+ emits a SyntaxWarning for x is 257. The rule stays simple: is for None and sentinels, == for values.

    a = 256
    b = 256
    print(a is b)          # True: CPython pre-allocates ints -5..256
    
    x = int("257")
    y = int("257")
    print(x is y)          # False: two separate int objects
    print(x == y)          # True: compare values with ==
    What interviewers listen for
    • CPython caches ints from -5 to 256
    • Larger ints are usually distinct objects
    • Constants in one code object may be shared
    • Implementation detail: never rely on it
    • Use == for values, is for None

    Likely follow-up: What is string interning?

  48. 48.What forms of unpacking does Python support? Show examples including star expressions.easy

    Unpacking assigns the items of any iterable to several names at once: x, y = point. The counts must match or you get a ValueError.

    Common patterns:

    • Swap without a temp: a, b = b, a
    • Star capture: first, *rest = items or head, *middle, tail = [1, 2, 3, 4, 5], where middle is always a list (possibly empty)
    • Nested: (x, y), label = (1, 2), "A"
    • In loops: for i, (name, score) in enumerate(pairs): and for key, value in d.items():
    • Ignoring values: _, ext = os.path.splitext(path)

    The same operators unpack inside literals and calls: [*a, *b] concatenates iterables, {**defaults, **overrides} merges dicts with later keys winning, and f(*args, **kwargs) spreads arguments. Since 3.9 you can also merge dicts with defaults | overrides.

    Unpacking works on any iterable, including strings and generators, and makes functions that return tuples read naturally: quotient, remainder = divmod(17, 5).

    What interviewers listen for
    • Assign iterable items to multiple names at once
    • Mismatched counts raise ValueError
    • *rest collects leftovers into a list
    • a, b = b, a swaps without a temporary
    • [*a, *b] and {**d1, **d2} in literals

    Likely follow-up: What does a, *b = [1] give for b?

  49. 49.How do f-strings work, and what formatting features do you use most?easy

    f-strings (Python 3.6+) embed expressions directly in a string literal: f"Hello {name}". They're evaluated at runtime, are usually the fastest formatting option, and are the most readable.

    After a : you use the same format-spec mini-language as format():

    • f"{price:.2f}" for two decimals, f"{n:,}" for thousands separators, f"{ratio:.0%}" for a percentage
    • f"{name:>10}" right-aligns in 10 characters; f"{n:08b}" is zero-padded binary
    • f"{d:%Y-%m-%d}" formats a date
    • !r uses repr(), and f"{x=}" (3.8+) prints x= followed by the value, great for debugging

    Python 3.12 (PEP 701) relaxed the grammar: you can reuse the same quote type inside the braces, use backslashes, and split expressions across lines. Python 3.14 adds t-strings (t"...", PEP 750), which produce a Template object instead of a str, so libraries can process the interpolated values safely, for example to escape SQL or HTML.

    One caveat: with logging, pass arguments separately (log.info("user %s", user)) so formatting happens only if the message is emitted.

    What interviewers listen for
    • Inline expressions, evaluated at runtime
    • Format spec after :, e.g. .2f, ,, >10
    • !r for repr; {x=} for debugging
    • 3.12 relaxed quoting rules; 3.14 adds t-strings
    • Prefer lazy %s arguments in logging calls

    Likely follow-up: How do you include a literal brace in an f-string?

  50. 50.What is PEP 8, and what makes code "Pythonic"?easy

    PEP 8 is the official style guide. The parts everyone should know: 4-space indentation; snake_case for functions and variables, PascalCase for classes, UPPER_CASE for constants; imports at the top, grouped as standard library, third-party, then local; and a 79-character line limit that many teams relax. In practice, formatters and linters like Black and Ruff enforce it automatically.

    "Pythonic" goes beyond formatting: it means using the language's idioms.

    • for i, item in enumerate(items) instead of range(len(items)), and zip() for parallel iteration
    • Truthiness: if not items: instead of if len(items) == 0:
    • is None instead of == None
    • Context managers for resources, comprehensions for simple transforms
    • Unpacking, dict.get() with a default, any() and all()
    • EAFP ("easier to ask forgiveness than permission"): attempt the operation and catch the exception, instead of checking everything first (LBYL)

    import this prints the Zen of Python, and "readability counts" is the principle behind all of it.

    What interviewers listen for
    • PEP 8: naming, 4 spaces, import order, line length
    • Automate style with Black or Ruff
    • enumerate, zip, unpacking over index loops
    • Truthiness checks and is None
    • EAFP: try and catch rather than pre-check

    Likely follow-up: When would you prefer LBYL over EAFP?

  51. 51.Which itertools functions do you find most useful, and why use them over hand-written loops?mid

    itertools provides fast, memory-efficient building blocks that accept any iterable and return lazy iterators:

    • Infinite: count(start), cycle(seq), repeat(x)
    • Slicing and filtering: islice(it, n) (slicing for iterators, like the first 10 items of a generator), takewhile, dropwhile, filterfalse
    • Combining: chain(a, b), chain.from_iterable(nested) to flatten one level, and zip_longest to pad the shorter input
    • Grouping: groupby(data, key) groups only consecutive equal keys, so sort by the same key first; pairwise (3.10+) yields overlapping pairs; batched(it, n) (3.12+) yields fixed-size chunks
    • Combinatorics: product, permutations, combinations, combinations_with_replacement
    • Accumulation and splitting: accumulate for running totals, tee to split one iterator into several

    They're implemented in C and compose into pipelines without building intermediate lists. Typical interview uses: product instead of nested loops, combinations for pair problems, and groupby after a sort.

    What interviewers listen for
    • Lazy, C-implemented iterator building blocks
    • islice slices any iterator, including infinite ones
    • chain concatenates; chain.from_iterable flattens one level
    • groupby needs input sorted by the same key
    • product, combinations, permutations replace nested loops

    Likely follow-up: Why does groupby on unsorted data return repeated keys?

  52. 52.What is the walrus operator :=, and when is it appropriate to use it?mid

    The walrus operator (Python 3.8, PEP 572) is an assignment expression: it assigns a value to a name and also evaluates to that value, so you can bind and test in one place.

    Typical uses:

    • Avoid calling something twice: if (m := pattern.search(line)) is not None: print(m.group(1))
    • Read-until-empty loops: while (chunk := f.read(8192)):
    • Reuse an expensive result in a comprehension: [y for x in data if (y := transform(x)) > 0]
    • Check and use a value: if (n := len(items)) > 10:

    Rules: an unparenthesized walrus can't be a statement on its own, so x := 5 is a SyntaxError while (x := 5) works; it can bind only plain names, not attributes or subscripts; and inside a comprehension it binds the name in the enclosing scope, so the name is still available after the comprehension.

    Use it where it removes repetition or an awkward pre-assignment. If it makes a condition harder to read, an ordinary assignment on the line above is better.

    What interviewers listen for
    • Assignment expression: binds and returns the value
    • Added in Python 3.8 (PEP 572)
    • Great for while read loops and regex matches
    • Only plain names; bare x := 5 is a SyntaxError
    • In comprehensions, binds in the enclosing scope

    Likely follow-up: What is the value of y after [y for x in data if (y := f(x))]?

  53. 53.How does the match statement work? Is it just a switch statement?mid

    match (Python 3.10+, PEP 634) is structural pattern matching, which is much more than a switch. It tries each case top to bottom, runs the first one that matches, and never falls through.

    Patterns can destructure data while matching:

    • Literals and or-patterns: case 200 | 201:
    • Sequence patterns: case ["go", direction]: checks the length and binds direction; *rest captures the remainder
    • Mapping patterns: case {"type": "click", "x": x}: matches dicts containing those keys (extra keys are allowed)
    • Class patterns: case Point(x=0, y=y): checks isinstance plus attributes
    • Guards with if, the _ wildcard, and as to bind a subpattern

    The big gotcha: a bare name is a capture pattern, not a comparison. case RED: matches anything and assigns it to RED; Python refuses to compile it if other cases follow. To compare against a constant, use a dotted name such as Color.RED.

    It shines for parsing commands, walking ASTs, and handling JSON-like messages or events by shape.

    def handle(command):
        match command.split():
            case ["go", direction]:                  # sequence pattern, binds a name
                return f"moving {direction}"
            case ["drop", *items] if items:          # star capture + guard
                return f"dropping {', '.join(items)}"
            case ["quit" | "exit"]:                  # or-pattern
                return "bye"
            case _:                                  # wildcard: matches anything
                return "unknown"
    
    print(handle("go north"))       # moving north
    print(handle("drop key map"))   # dropping key, map
    What interviewers listen for
    • Structural pattern matching, Python 3.10+
    • First matching case wins; no fall-through
    • Sequence, mapping, class and or-patterns destructure data
    • Bare names capture; use dotted names for constants
    • Guards add conditions with if

    Likely follow-up: How would you match a dataclass instance by its fields?

  54. 54.What are abstract base classes (abc.ABC), and when would you use them?mid

    An abstract base class defines an interface that subclasses must implement. You inherit from abc.ABC and mark required methods with @abstractmethod. Neither the ABC nor any subclass that hasn't overridden every abstract method can be instantiated: you get a TypeError saying it can't instantiate an abstract class without an implementation for the missing method. That moves the error from "when the missing method is called" to "when the object is created".

    ABCs can also supply concrete methods built on the abstract ones (the template-method pattern), and @abstractmethod combines with @property or @classmethod.

    The standard library's collections.abc is the most common use: subclass Mapping and implement __getitem__, __iter__ and __len__, and you get keys, items, get and in for free. isinstance(x, collections.abc.Iterable) is also the idiomatic capability check.

    Versus typing.Protocol: an ABC is nominal (explicit inheritance) and enforced at runtime; a Protocol is structural and aimed at static checkers.

    What interviewers listen for
    • Subclass ABC, mark methods @abstractmethod
    • Incomplete subclasses raise TypeError on instantiation
    • Can provide concrete methods built on abstract ones
    • collections.abc mixins give methods for free
    • ABC is nominal; Protocol is structural

    Likely follow-up: What does ABC.register() do? · When would you choose a Protocol instead?

  55. 55.What are typing.Protocol and TypedDict, and when would you use each?hard

    Both come from typing and matter mainly to static type checkers.

    A Protocol describes a shape: any class with matching methods and attributes is compatible, no inheritance required. That's structural typing, or static duck typing. For example, a SupportsClose protocol with a close(self) -> None method accepts files, sockets or your own classes in def shutdown(r: SupportsClose). It's ideal for describing what a function needs from third-party objects you can't make inherit from your base class. @runtime_checkable enables isinstance() checks, but those only verify that the members exist, not their signatures.

    A TypedDict describes a dict with a fixed set of string keys and a type per key, like a JSON payload with title: str and year: int. At runtime it's a plain dict with no validation. total=False, or Required/NotRequired (3.11+) per key, controls which keys are optional, and ReadOnly (3.13+) marks keys that shouldn't be modified.

    Rule of thumb: TypedDict when the data really is a dict (parsed JSON), a dataclass when you control the type, and Protocol to type behavior.

    What interviewers listen for
    • Protocol: structural typing, no inheritance needed
    • runtime_checkable checks presence, not signatures
    • TypedDict: typed keys, plain dict at runtime
    • NotRequired / total=False for optional keys
    • Neither validates data at runtime

    Likely follow-up: How is a Protocol different from an ABC? · How would you validate a TypedDict payload at runtime?

  56. 56.How do you write tests with pytest? What are fixtures and how do they work?mid

    pytest discovers files named test_*.py or *_test.py and functions whose names start with test. Tests use plain assert statements, which pytest rewrites to show the compared values on failure, so there's no need for self.assertEqual.

    A fixture is a function decorated with @pytest.fixture that provides setup for tests. A test requests one simply by naming it as a parameter, and pytest injects the result. With yield, code before it is setup and code after it is teardown, which runs even when the test fails.

    Key features:

    • Scope: "function" (the default) runs per test; "class", "module", "package" or "session" share expensive resources such as a database connection
    • conftest.py: fixtures defined there are available to every test in that directory and below, without imports
    • Fixtures can depend on other fixtures, and autouse=True applies one automatically
    • Built-in fixtures: tmp_path, monkeypatch, capsys, caplog
    • @pytest.mark.parametrize runs one test with many inputs

    Fixtures beat setUp methods because each test declares exactly what it needs.

    import pytest
    @pytest.fixture
    def db():
        conn = make_test_db()      # setup
        yield conn                 # the value injected into the test
        conn.close()               # teardown: runs even if the test fails
    
    def test_insert(db):           # requested by parameter name
        db.insert("ana")
        assert db.count() == 1
    
    @pytest.mark.parametrize("n, expected", [(2, True), (3, False)])
    def test_is_even(n, expected):
        assert (n % 2 == 0) == expected
    What interviewers listen for
    • Plain assert with rewritten failure messages
    • Fixtures injected by parameter name
    • yield fixtures: setup before, teardown after
    • Scopes from function to session; share via conftest.py
    • parametrize runs one test over many inputs

    Likely follow-up: How would you mock an external API call in a pytest test? · What is the difference between tmp_path and tmpdir?

  57. 57.What is the difference between __new__ and __init__? When do you need __new__?hard

    Calling Cls(...) runs two steps. First __new__(cls, ...), an implicit static method, creates and returns the new object, normally via super().__new__(cls). Then, if the returned object is an instance of cls, Python calls __init__(self, ...) on it to initialize it. __init__ must return None.

    You almost always need only __init__. __new__ is for controlling creation itself:

    • Subclassing immutable types like int, str or tuple: the value is fixed at creation, so __init__ is too late. A class Upper(str) must transform the value in __new__.
    • Returning an existing object instead of a new one: instance caching, interning, or a singleton.
    • Metaclasses, whose __new__ builds the class object itself.

    Subtleties: if __new__ returns something that isn't an instance of cls, __init__ is skipped. With a singleton implemented in __new__, __init__ still runs on every call and can reset state, a classic gotcha. For singletons, a module-level instance is usually the more Pythonic answer.

    What interviewers listen for
    • __new__ creates the object; __init__ initializes it
    • __init__ runs only if __new__ returns a cls instance
    • Needed to subclass immutable types like str
    • Used for caching, interning and singletons
    • Singletons via __new__ still re-run __init__

    Likely follow-up: How would you implement a singleton in Python?

  58. 58.What is a descriptor, and how does @property work under the hood?hard

    A descriptor is an object stored as a class attribute that defines __get__, and optionally __set__ or __delete__. When you access that attribute through an instance, Python calls those methods instead of doing a plain lookup. It's the mechanism behind property, classmethod, staticmethod, __slots__ and even ordinary methods: functions are descriptors whose __get__ returns a bound method, which is how self gets passed.

    There are two kinds:

    • Data descriptors define __set__ or __delete__ and take priority over the instance's __dict__. That's why a property can't be shadowed by assigning to the instance.
    • Non-data descriptors define only __get__ (like functions); an instance attribute with the same name wins over them.

    __set_name__ (3.6+) tells the descriptor which attribute name it was assigned to. The example is a reusable validated field: any class can declare qty = Positive() and every assignment is checked, where a property would have to be repeated per attribute. ORMs such as Django and SQLAlchemy use descriptors for model fields.

    class Positive:
        def __set_name__(self, owner, name):
            self.name = "_" + name              # called when the class is created
        def __get__(self, obj, objtype=None):
            return self if obj is None else getattr(obj, self.name)
        def __set__(self, obj, value):
            if value <= 0:
                raise ValueError(f"{self.name[1:]} must be positive")
            setattr(obj, self.name, value)
    
    class Order:
        qty = Positive()                        # a descriptor lives on the class
        def __init__(self, qty):
            self.qty = qty                      # goes through Positive.__set__
    What interviewers listen for
    • Class attribute defining __get__/__set__/__delete__
    • Powers property, methods, classmethod, staticmethod
    • Data descriptors override the instance __dict__
    • Non-data descriptors are shadowed by instance attributes
    • __set_name__ learns the attribute name

    Likely follow-up: How are bound methods created? · Why can't an instance attribute shadow a property?

  59. 59.What is a metaclass? When would you use one, and what are the simpler alternatives?hard

    Classes are objects too, and a metaclass is the class of a class: it controls how class objects are created. The default metaclass is type, so type(int) is type. When Python executes a class statement, it runs the body to collect a namespace and then calls the metaclass, roughly type(name, bases, namespace), to build the class. A custom metaclass subclasses type and overrides __new__ or __init__ to inspect or modify classes as they're created: registering them, validating required attributes, or adding methods.

    Real examples: abc.ABCMeta behind ABC, enum.EnumType (formerly EnumMeta) behind Enum, and model base classes in ORMs like Django's.

    For application code, lighter tools usually do the job:

    • __init_subclass__ (3.6+) runs whenever a class is subclassed, great for plugin registries and validation
    • Class decorators modify a class after it's created
    • __set_name__ on descriptors

    Metaclasses also complicate inheritance: combining bases with unrelated metaclasses raises a "metaclass conflict" TypeError.

    What interviewers listen for
    • A metaclass is the class of a class
    • type is the default metaclass
    • Hooks class creation: register, validate, modify
    • Prefer __init_subclass__ or class decorators
    • Unrelated metaclasses cause a metaclass conflict

    Likely follow-up: How would you build a plugin registry with __init_subclass__?

  60. 60.What is free-threaded Python, and should you use it today?hard

    Free-threaded Python is a CPython build with the GIL removed (PEP 703), so threads can run Python code on multiple cores in parallel. It's a separate build, typically installed alongside the normal one with a t suffix (for example python3.14t), not the default interpreter. It was experimental in 3.13; in 3.14 it became officially supported (PEP 779) but is still optional.

    What I'd say, with appropriate hedging:

    • CPU-bound threads can scale across cores without multiprocessing.
    • Single-threaded code runs somewhat slower on this build; the 3.14 release notes put it at roughly 5-10%, depending on platform and compiler.
    • C extensions must opt in. Importing one that isn't marked as supporting free threading re-enables the GIL for the process and prints a warning, so the ecosystem is still catching up.
    • Thread safety is still your job. Built-in containers use internal locks so they don't get corrupted, but compound operations like check-then-set still need a threading.Lock.

    Check at runtime with sys._is_gil_enabled(), and control it with PYTHON_GIL or -X gil. I'd benchmark per workload before adopting it.

    What interviewers listen for
    • Optional CPython build without the GIL (PEP 703)
    • Experimental in 3.13, officially supported in 3.14
    • Single-threaded code pays some overhead
    • Non-opted-in C extensions re-enable the GIL
    • Still need locks for compound operations

    Likely follow-up: How would you check whether the GIL is active? · Does free threading make asyncio obsolete?

Prefer multiple choice? All 22 Python MCQs with answers →

esc