Chapter 6 — Advanced Python (60 Questions)

Topics (60 questions):

  1. 1. How does the Global Interpreter Lock (GIL) work in CPython
  2. 2. What is the difference between multithreading and multiprocessing in Python
  3. 3. How does CPython memory management work
  4. 4. What are reference counting and cyclic garbage collection
  5. 5. What is string and integer interning in Python
  6. 6. What is the difference between __slots__ and __dict__ in Python
  7. 7. How does the Python import system work
  8. 8. What is the difference between a module and a package in Python
  9. 9. How does CPython execute bytecode
  10. 10. What is the difference between CPython, PyPy, and Cython
  11. 11. What is the Python data model
  12. 12. What is the difference between __new__ and __init__ in Python
  13. 13. What are descriptors in Python
  14. 14. How do property, classmethod, and staticmethod work internally
  15. 15. What is a metaclass and when would you use one
  16. 16. How does method resolution order (MRO) work in Python
  17. 17. What is the difference between __getattr__, __getattribute__, and __setattr__
  18. 18. What dunder methods should you know for Python interviews
  19. 19. How do you implement a context manager in Python
  20. 20. What is the difference between __enter__/__exit__ and contextlib
  21. 21. What is the difference between __str__ and __repr__ in Python
  22. 22. What is the difference between equality and identity in Python
  23. 23. How do closures work in Python
  24. 24. How do you write a decorator that accepts arguments in Python
  25. 25. What is the difference between a function decorator and a class decorator
  26. 26. What are TypeVar, Protocol, and structural typing in Python
  27. 27. What is the difference between Optional, T | None, and NotRequired
  28. 28. How do dataclasses compare to Pydantic models
  29. 29. What is functools.lru_cache and when should you not use it
  30. 30. What is the difference between copy.copy and copy.deepcopy
  31. 31. How does functools.partial work
  32. 32. What are structural pattern matching and when should you use match
  33. 33. What is the difference between an iterable, an iterator, and a generator in Python
  34. 34. How does yield from work in Python
  35. 35. What is the difference between generator expressions and list comprehensions
  36. 36. How do you implement a custom iterator in Python
  37. 37. What is the difference between deque, list, and array in Python
  38. 38. How do dictionaries work internally in modern Python
  39. 39. What is a weakref and when would you use it in Python
  40. 40. What are the trade-offs between namedtuple, TypedDict, and dataclass
  41. 41. How does asyncio work in Python
  42. 42. What is the difference between async def, coroutines, and tasks
  43. 43. What is the asyncio event loop and how do you avoid blocking it
  44. 44. What is the difference between asyncio.gather, TaskGroup, and wait
  45. 45. How do you mix blocking I/O with asyncio
  46. 46. What is the difference between threading.Lock, asyncio.Lock, and multiprocessing.Lock
  47. 47. What is a deadlock in Python concurrency and how do you prevent it
  48. 48. How does concurrent.futures compare to asyncio
  49. 49. How do you choose a concurrency strategy for CPU-bound vs I/O-bound Python
  50. 50. How would you design a high-throughput Python service given the GIL
  51. 51. How do you profile and optimize a slow Python application
  52. 52. What is the difference between pickle, JSON, and MessagePack
  53. 53. How does Python packaging work with pyproject.toml
  54. 54. What is the difference between venv, Poetry, uv, and Conda
  55. 55. How do you handle circular imports in Python
  56. 56. What are common Python memory leaks and how do you find them
  57. 57. How do you make Python code thread-safe
  58. 58. What is monkey patching and why is it risky in production
  59. 59. How do you design testable Python code with pytest
  60. 60. What are important modern Python features a senior engineer should know

1. How does the Global Interpreter Lock (GIL) work in CPython?

Interview Answer

The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time. It exists because CPython’s memory management (especially reference counting) is not thread-safe. The lock simplifies the interpreter at the cost of parallel CPU-bound Python threads.

I/O-bound threads still benefit: a thread releases the GIL around blocking I/O, so other threads can run. CPU-bound threads mostly take turns on one core.

Example of why threads do not speed up CPU work:

import threading

def burn():
    n = 0
    for _ in range(20_000_000):
        n += 1

threads = [threading.Thread(target=burn) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

Those four threads typically finish no faster than one thread, and can be slower because of lock contention.


Important Point

The GIL is a CPython implementation detail, not a language rule. PyPy has a GIL too; Jython and IronPython historically did not. CPU parallelism in CPython means multiprocessing, native extensions that release the GIL, or moving work off-process (queues, containers, other languages).


Follow-up Question

Q: Does asyncio avoid the GIL?

Answer

No. asyncio still runs on one thread and still needs the GIL to execute bytecode. It avoids blocking that thread by using non-blocking I/O and cooperative tasks. CPU-heavy work inside a coroutine still holds the GIL and stalls the event loop.


2. What is the difference between multithreading and multiprocessing in Python?

Interview Answer

Threads share one process, one memory space, and one GIL. They are cheap to start and good for concurrent I/O, but they do not give true parallel Python bytecode.

A senior answer also mentions concurrent.futures.ThreadPoolExecutor vs ProcessPoolExecutor: same API, different isolation and cost.


Important Point

Prefer threads for many waiting connections or blocking library calls that release the GIL. Prefer processes for NumPy-style CPU work, CPU-bound Python, or when you need a crash boundary. Mixing both (process pool + async I/O in the parent) is common in services.


Follow-up Question

Q: Why can multiprocessing be slower than threading?

Answer

Process start-up, pickle serialization, and extra copies of data can dominate short tasks. If the payload is large or the work is tiny, threads (or a long-lived process pool) win. Always measure with realistic batch sizes.


3. How does CPython memory management work?

Interview Answer

CPython uses several layers:

  1. Reference counting — every object has an ob_refcnt. When it hits zero, the object is deallocated immediately.
  2. Cyclic garbage collector — finds reference cycles (lists/dicts/objects pointing at each other) that refcounting cannot free.
  3. Object allocators — small objects go through pymalloc arenas/pools; large objects use the system allocator.
  4. Interning / singletons — small ints and some strings are reused.

This is why dropping the last reference usually frees memory quickly, but long-lived caches, cycles with __del__, and native buffers (NumPy, sockets) can still pin memory.


Important Point

Python does not compact the heap like some JVM collectors. Fragmentation and native allocations can keep RSS high even after objects are gone. gc.collect() only helps cycles; it does not shrink the process to the OS in all cases.


Follow-up Question

Q: Where does a memory leak usually hide in Python?

Answer

Caches without bounds (lru_cache on unbounded unique keys), global lists, traceback/exception frames, lingering closures, unclosed files/sockets, and C extensions that forget to decrement references.


4. What are reference counting and cyclic garbage collection?

Interview Answer

Reference counting is deterministic: a = [] then del a frees the list immediately if nothing else points at it. Assignment, argument passing, and being stored in a container all increment the count.

Cycles break that:

a = []
b = []
a.append(b)
b.append(a)
del a, b  # cycle remains until gc runs

The cyclic GC tracks container objects in generations, periodically looks for unreachable cycles, and breaks them. Objects with __del__ in a cycle are harder; they can end up in gc.garbage in older versions and delay collection.


Important Point

You can inspect with sys.getrefcount(obj) (the call itself adds a temporary reference) and control GC with the gc module. In performance-sensitive code, reducing pointer-chasing objects matters more than calling gc.collect() in a loop.


Follow-up Question

Q: Does refcounting make Python real-time?

Answer

No. Deallocation of a large graph can stall, the cyclic GC pauses, and many libraries allocate outside the Python heap. Refcounting is timely for simple objects, not a real-time guarantee.


5. What is string and integer interning in Python?

Interview Answer

Interning reuses one object for some immutable values so identity (is) and memory can be cheaper.

This is why a = 256; b = 256; a is b is often true, while a = 257; b = 257 may be false depending on how the values were created. Never use is to compare numbers or strings for equality.


Important Point

Interning is an optimization, not a language contract. Code that relies on is for ints/strings is a bug waiting for another Python version or a different code path (runtime concatenation vs literals).


Follow-up Question

Q: When should you call sys.intern?

Answer

When you have a high volume of repeated strings used as dict/set keys (parsers, tokenizers). Interning unique strings wastes memory. Profile before using it as a default habit.


6. What is the difference between __slots__ and __dict__ in Python?

Interview Answer

By default each instance has a __dict__ mapping attribute names to values. That is flexible (you can add attributes at runtime) and relatively heavy per object.

__slots__ declares a fixed set of attributes. CPython can store them in a compact array and omit __dict__ (unless you include '__dict__' in slots).

class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

Use slots for millions of small objects (graph nodes, particles). Do not use them as a security feature; they are a memory/layout tool.


Important Point

Inheritance is tricky: if a base class has a dict, subclasses still get one. Multiple inheritance with slots is error-prone. Pickle, weakrefs, and some ORMs expect __dict__ unless you also slot __weakref__.


Follow-up Question

Q: Does __slots__ make attribute access faster?

Answer

Often slightly, because it skips a dict lookup, but the usual win is memory. If speed is the goal, measure; algorithms and locality dominate micro-optimizations.


7. How does the Python import system work?

Interview Answer

Import is a runtime protocol, not just reading a file.

  1. Check sys.modules — if the name is there, return that module (even if the value is None, which can signal a failed import).
  2. Find a loader via sys.meta_path finders (builtin, frozen, path finder).
  3. The path finder walks sys.path / sys.path_hooks, looking for packages (__init__.py or namespace packages) and modules.
  4. The loader executes the module body in the module’s namespace and caches it in sys.modules before execution finishes (this is how circular imports partially work).

Absolute imports are the default in Python 3. Relative imports (from . import utils) only work inside packages.


Important Point

importlib.reload re-executes a module but does not magically update existing object references. In production, prefer process restart over reload. Understand PYTHONPATH, editable installs, and namespace packages when debugging “wrong module” issues.


Follow-up Question

Q: Why does import sometimes see a half-initialized module?

Answer

Because the module is placed in sys.modules before its body finishes. A circular import can then read attributes that are not defined yet, causing AttributeError. The fix is usually to invert dependencies, import inside a function, or extract a small shared module.


8. What is the difference between a module and a package in Python?

Interview Answer

A module is an importable unit — usually one .py file, but also C extensions and namespace pieces. A package is a module that contains other modules. Regular packages have a directory and historically __init__.py. Namespace packages can span directories without __init__.py.

__init__.py runs when the package is imported and is the place to re-export a public API. Heavy work in __init__.py slows every import of that tree.

from pkg import * is controlled by __all__. Seniors treat that as a convenience for shells, not as an application import style.


Important Point

Distribution packages (wheels on PyPI) are not the same thing as import packages. One wheel can install several import packages. Name them carefully to avoid shadowing stdlib modules (email.py, json.py in the working directory).


Follow-up Question

Q: What is a namespace package used for?

Answer

Split a single import package across multiple distributions (plugin architectures, google.* style). They are harder to reason about; use them when you truly need separately installed subpackages.


9. How does CPython execute bytecode?

Interview Answer

Source is parsed to an AST, compiled to bytecode (.pyc in __pycache__), then run by a stack-based evaluator (the ceval loop). Each function has a code object with bytecode, constants, and names.

import dis

def add(a, b):
    return a + b

dis.dis(add)

You will see instructions such as LOAD_FAST, BINARY_OP, RETURN_VALUE. CPython 3.11+ added a specializing adaptive interpreter (PEP 659): hot instructions can become type-specialized (for example integer add) and deoptimize if types change.


Important Point

This is why “Python is interpreted” is incomplete: it compiles to bytecode, then interprets/specializes that bytecode. Tracing JITs (PyPy) and compilers (Cython, Nuitka, mypyc) take different paths. dis, perf, and specialized opcodes are fair game in senior interviews.


Follow-up Question

Q: Do .pyc files make Python as fast as C?

Answer

No. They skip parse/compile on later imports. Runtime is still the evaluator plus object overhead. Speedups come from algorithms, C extensions, PyPy, or rewriting hot loops.


10. What is the difference between CPython, PyPy, and Cython?

Interview Answer

Choose CPython by default for compatibility. Reach for Cython or a native library (NumPy, pandas) when a profiler shows a tight loop. Consider PyPy when the app is long-lived, pure-Python, and extension compatibility is acceptable.


Important Point

Interviewers want you to know that “rewrite it in C” is not the first step. Profile, then pick the smallest lever: algorithm, vectorization, PyPy, Cython, or another service.


Follow-up Question

Q: Can you mix them in one process?

Answer

Cython modules load into CPython easily. PyPy and CPython do not share one process. You can still combine them as separate services or via multiprocessing with a CPython worker pool.


11. What is the Python data model?

Interview Answer

The data model is how user objects hook into the language: iteration, attribute access, arithmetic, context managers, async, pickling, and more. You opt in by implementing dunder methods.

Examples:

Python is protocol-oriented: if it walks like a sequence, for and len() work. You rarely need to inherit from a special base class.


Important Point

A senior answer mentions that builtins call these hooks (sometimes via C slots for speed) and that inconsistent implementations (__eq__ without __hash__) create subtle bugs.


Follow-up Question

Q: Is the data model the same as OOP?

Answer

It includes OOP but is broader. It is the contract between your types and the interpreter. You can implement protocols with functions and modules too; classes are just the usual place dunders live.


12. What is the difference between __new__ and __init__ in Python?

Interview Answer

__new__ is a static/constructor hook that creates the instance (usually by calling object.__new__(cls)). __init__ then initializes the already-created instance.

Most classes only need __init__. You override __new__ when:

class Meter(float):
    def __new__(cls, value):
        return super().__new__(cls, float(value))

If __new__ returns an object that is not an instance of cls, __init__ is skipped.


Important Point

Interview trap: people put side effects in __new__. Keep allocation in __new__ and mutation in __init__ unless you have a concrete reason.


Follow-up Question

Q: Can __init__ return a value?

Answer

No. __init__ must return None. Returning anything else raises TypeError. Construction customization belongs in __new__ or a factory function.


13. What are descriptors in Python?

Interview Answer

A descriptor is an object that defines __get__, __set__, and/or __delete__ and is stored on a class. Attribute access is delegated to it. This is how properties, methods, classmethod, and staticmethod work.

class Positive:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, owner):
        if obj is None:
            return self
        return obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError('must be >= 0')
        obj.__dict__[self.name] = value

Data descriptors (__set__ or __delete__) override instance dict entries. Non-data descriptors (only __get__, like functions) are overridden by an instance attribute of the same name.


Important Point

If you understand descriptors, ORM columns, property, and bound methods stop being magic. __set_name__ (PEP 487) lets the descriptor know its attribute name without a metaclass.


Follow-up Question

Q: Why does assigning to a property on the class replace the property?

Answer

Because you stored a new object on the class dict, replacing the descriptor. Instance assignment goes through __set__ if it exists. Class-level assignment is just mutating the class namespace.


14. How do property, classmethod, and staticmethod work internally?

Interview Answer

They are descriptors:

That is why obj.method is not the same object as Cls.method: you get a bound method wrapper. In 3.11+ some of this is faster, but the model is the same.

Use classmethod for alternate constructors (from_json). Use staticmethod sparingly; a module-level function is often clearer unless you need it on the class for API reasons.


Important Point

@property plus a setter is still a descriptor pair. Inheritance of properties is straightforward; inheritance of classmethod that refers to cls is the usual way to keep subclass factories working.


Follow-up Question

Q: Can a classmethod be a property?

Answer

Yes, via classmethod(property(...)) ordering subtleties in older versions, or dedicated recipes. In interviews, say it is awkward; prefer an explicit class method def config(cls) unless a library already uses the stacked decorator pattern.


15. What is a metaclass and when would you use one?

Interview Answer

A class is an instance of a metaclass (usually type). A metaclass customizes class creation: it can rewrite the class dict, register subclasses, validate attributes, or generate methods.

class Registry(type):
    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        if getattr(cls, 'abstract', False):
            return
        PLUGIN_MAP[cls.name] = cls

You almost never need a metaclass. Prefer:


Important Point

Frameworks (Django models, SQLAlchemy, ABCs, enums, protocol checks) still use metaclasses because they must run when the class statement finishes. In an interview, show you can write one and also that you would not reach for it first.


Follow-up Question

Q: What is the metaclass conflict?

Answer

If you inherit two classes with different metaclasses that are not subclasses of one another, Python raises TypeError. The metaclass of the new class must be a subtype of each base metaclass. This shows up when mixing ORMs, ABCs, and enums.


16. How does method resolution order (MRO) work in Python?

Interview Answer

Python 3 uses the C3 linearization algorithm. For a class, Cls.__mro__ is a tuple of types searched left-to-right for attributes. super() follows that MRO, not “the parent class” in a naive way.

class A: 
    def f(self): return 'A'
class B(A):
    def f(self): return 'B' + super().f()
class C(A):
    def f(self): return 'C' + super().f()
class D(B, C):
    pass
# D.__mro__ == (D, B, C, A, object)

C3 keeps local precedence (B before C in class D(B, C)) and monotonicity. If no consistent order exists, class creation fails.


Important Point

Cooperative multiple inheritance requires every class to use super() and compatible signatures (often *args, **kwargs in mixins). Diamond inheritance is normal in Python; fear of diamonds comes from languages with different MRO rules.


Follow-up Question

Q: What does super() without arguments do?

Answer

In a method, zero-argument super() is compiler-magic: it binds to the current class and instance/class. At the class level or in a nested function you must pass super(type, obj) explicitly.


17. What is the difference between __getattr__, __getattribute__, and __setattr__?

Interview Answer

ORMs and lazy loaders typically use __getattr__. Security proxies sometimes wrap __getattribute__. Overriding __getattribute__ is a last resort because it is on the hottest path in the language.


Important Point

If both exist, __getattribute__ runs first; it may raise AttributeError, which then triggers __getattr__.


Follow-up Question

Q: Why does logging inside __getattribute__ hang or recurse?

Answer

Because logging formats self or reads attributes, which calls __getattribute__ again. Always bypass with object.__getattribute__ for internal state.


18. What dunder methods should you know for Python interviews?

Interview Answer

A practical senior set:


Important Point

You do not memorize every numeric dunder. You do know the hash/eq rule: if you override __eq__ and the object is mutable, set __hash__ = None. Immutable value objects should hash the same fields they compare.


Follow-up Question

Q: Why is __del__ a poor substitute for close()?

Answer

GC order is not guaranteed, cycles delay it, interpreters may not run it at shutdown, and exceptions in __del__ are ignored. Use context managers and explicit close().


19. How do you implement a context manager in Python?

Interview Answer

The with statement calls __enter__ and, on the way out, __exit__(exc_type, exc, tb). Returning a true value from __exit__ swallows the exception.

class timed:
    def __enter__(self):
        self.t0 = time.perf_counter()
        return self
    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.t0
        return False

Generator form:

from contextlib import contextmanager

@contextmanager
def timed():
    t0 = time.perf_counter()
    try:
        yield
    finally:
        print(time.perf_counter() - t0)

Important Point

Async code uses __aenter__ / __aexit__ and async with. Always release resources in finally equivalent paths. Do not swallow exceptions unless that is the API (for example a library that converts errors).


Follow-up Question

Q: What should __exit__ return?

Answer

Usually False or None so exceptions propagate. Return True only when the manager is designed to suppress a specific error type, and document it.


20. What is the difference between __enter__/__exit__ and contextlib?

Interview Answer

__enter__/__exit__ are the protocol. contextlib is stdlib sugar:

Use a class when the manager has rich state or needs to be subclassed. Use @contextmanager for short resource wrappers. Use ExitStack when opening a variable number of files or connections.


Important Point

Seniors mention contextlib.ExitStack in tests and in functions that acquire optional resources. Swallowing errors with suppress(FileNotFoundError) is clearer than a bare except.


Follow-up Question

Q: Is contextlib.contextmanager slower?

Answer

It has generator overhead, which is irrelevant for I/O. For a nanosecond-scale helper in a tight loop, a dedicated class or no with at all may be better. Profile before caring.


21. What is the difference between __str__ and __repr__ in Python?

Interview Answer

__repr__ is the unambiguous developer representation (debugging, logging, REPL). Convention: if practical, eval(repr(x)) would recreate the object, or at least include the type and key fields.

__str__ is the readable user-facing string. print and str() use it. If you only define __repr__, str() falls back to it. The reverse is not true: missing __repr__ yields the default <Cls at 0x...>.

Containers use repr of their items. That is why a bad __repr__ makes logs unusable.


Important Point

Never put secrets in __repr__ (tokens, passwords). For dataclasses, repr=True is automatic; disable or customize fields that are huge or sensitive.


Follow-up Question

Q: Why do people implement only __repr__?

Answer

Because one good developer string is enough for many internal types. Add __str__ when you have a distinct user-facing format (money, locale dates).


22. What is the difference between equality and identity in Python?

Interview Answer

== calls __eq__ (equality of value). is compares object identity (same memory object).

Use is for singletons: None, True, False, sentinels. Use == for numbers, strings, and domain values.

Interning makes is accidentally work for some small ints and interned strings, which teaches a bad habit. In boolean contexts, write if x is None, not if x == None.


Important Point

If you implement __eq__, decide hashing: mutable objects that compare by value should be unhashable. Equal objects must have equal hashes if they are hashable.


Follow-up Question

Q: Why is “if x is True” usually wrong?

Answer

Because many truthy values are not the singleton True (numpy bools, custom objects). Prefer if x: or an explicit typed check. Use is True only when you must distinguish True from other truthy values.


23. How do closures work in Python?

Interview Answer

A closure is a function that captures variables from an enclosing scope. Those cells live as long as the inner function does.

def make_adder(n):
    def add(x):
        return x + n
    return add

add5 = make_adder(5)
add5(2)  # 7

Late binding is the classic trap in loops:

funcs = [lambda: i for i in range(3)]
funcs[0]()  # 2, not 0

All lambdas close over the same i. Fix with a default argument: lambda i=i: i.


Important Point

Closures enable decorators and callbacks. Inspect with fn.__closure__ and fn.__code__.co_freevars. Assigning to a captured name in Python 3 requires nonlocal; otherwise you create a local.


Follow-up Question

Q: When should you use nonlocal vs a mutable box?

Answer

nonlocal is the right tool for a captured counter. A one-element list or a small class is an older workaround. Prefer clear state objects if several values mutate.


24. How do you write a decorator that accepts arguments in Python?

Interview Answer

A decorator is a callable that takes a function and returns a function (or a descriptor). With arguments you need an extra layer:

import functools

def retry(times):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
        return wrapper
    return deco

Always use functools.wraps so __name__, __doc__, and signature introspection stay correct. For async functions, detect coroutines and await them, or provide a separate async decorator.


Important Point

Class decorators and decorator factories that return classes are also valid. functools.singledispatch is a stdlib example of a decorator with registration APIs.


Follow-up Question

Q: How do you keep type checkers happy?

Answer

Use ParamSpec and TypeVar for the wrapped callable, or typing.Concatenate when injecting arguments. Many codebases start with untyped wrappers and tighten later; seniors mention ParamSpec in 3.10+.


25. What is the difference between a function decorator and a class decorator?

Interview Answer

A function decorator wraps or replaces a function. A class decorator receives the class object after the class body runs and returns a class (possibly the same one).

Class decorator example: register a model, freeze a dataclass, or add methods. It runs later than the class body but earlier than most instance creation.

A decorator implemented as a class with __call__ is still a function decorator from the caller’s point of view — it is just stateful. That is useful for counters and caches bound to the decorator instance.


Important Point

Order matters: @deco_a then @deco_b means deco_a(deco_b(fn)). Stacked class decorators follow the same inside-out rule.


Follow-up Question

Q: When is a metaclass better than a class decorator?

Answer

When you must customize how the class is built (the namespace, the metaclass of subclasses, __prepare__). For wrapping or registering an already-built class, a decorator or __init_subclass__ is simpler.


26. What are TypeVar, Protocol, and structural typing in Python?

Interview Answer

Python typing is gradual. At runtime, most hints are ignored (unless you use pydantic, beartype, or similar).

Nominal typing uses classes and ABCs. Structural typing with Protocol is better for “file-like” and “closeable” without forcing inheritance.


Important Point

Seniors know hints can lie: they are not enforced. Treat them as contracts for mypy/pyright and humans. typing.cast is an escape hatch, not a runtime conversion.


Follow-up Question

Q: What is the difference between ABC and Protocol?

Answer

ABCs are nominal (you inherit or register). Protocols are structural (if the methods exist, the type matches). Use ABC when you own the hierarchy; Protocol when you want to accept third-party types.


27. What is the difference between Optional, T | None, and NotRequired?

Interview Answer

APIs should not mix “missing” and “null” without documenting it. JSON, databases, and HTTP query params all treat absence differently from null.


Important Point

For functions, def f(x: int | None = None) is a common pattern. Do not use a mutable default; use None and assign inside the body.


Follow-up Question

Q: Why is Optional[List[str]] = None better than default []?

Answer

Because default [] is a single shared list. That is the mutable default argument bug. None plus x = x or [] (or if x is None) is the correct pattern.


28. How do dataclasses compare to Pydantic models?

Interview Answer

@dataclass generates __init__, __repr__, and optional __eq__/__hash__. It is a class builder, not a validator. Fields keep whatever you pass unless you add your own __post_init__.

Pydantic (v2) is a validation and serialization library: type coercion, nested models, JSON schema, aliases, and strict vs lax modes. It is heavier and excellent at API boundaries.

attrs is the older, more configurable relative of dataclasses. Namedtuples are still fine for tiny immutable rows.

Rule of thumb: dataclasses/attrs inside the domain; Pydantic at the edge (HTTP, messages, config). Mixing both is normal.


Important Point

Frozen dataclasses plus slots=True (3.10+) give cheap immutable value objects. Pydantic’s model_config frozen models are for validated payloads, not for every inner loop object.


Follow-up Question

Q: Can pydantic replace mypy?

Answer

No. Pydantic checks values at runtime at the boundary. mypy/pyright check code paths without running them. You want both for a serious service.


29. What is functools.lru_cache and when should you not use it?

Interview Answer

@lru_cache(maxsize=128) memoizes a function. Keys are the arguments; they must be hashable. maxsize=None is an unbounded cache (cache in 3.9+).

Do not use it when:


Important Point

There is also @cache and cached_property. For instance methods, an LRU cache on the method can pin self and leak instances — prefer cached_property or a weak-keyed cache.


Follow-up Question

Q: Is lru_cache thread-safe?

Answer

The CPython implementation uses a lock around cache updates. It is safe from corruption, but stampedes and GIL contention can still happen. It is not a distributed cache.


30. What is the difference between copy.copy and copy.deepcopy?

Interview Answer

copy.copy is shallow: a new container, same inner objects. copy.deepcopy recursively copies, using a memo dict to handle cycles.

a = [[1], [2]]
b = copy.copy(a)
b[0].append(9)
# a[0] is also [1, 9]

Many types implement __copy__/__deepcopy__. Custom classes that hold graphs should define them or they may share state unexpectedly. Deepcopy is slow and can copy too much (thread locks, open files).


Important Point

Prefer explicit constructors (replace on dataclasses, factory methods) over deepcopy in domain code. Deepcopy is a blunt tool for snapshotting nested dict/list trees.


Follow-up Question

Q: Does slicing a list deepcopy?

Answer

No. new = old[:] is a shallow copy of the list. Nested mutables are still shared.


31. How does functools.partial work?

Interview Answer

functools.partial(fn, *args, **kwargs) freezes some arguments and returns a callable. It is lighter than a nested def for callbacks.

from functools import partial
from operator import mul
double = partial(mul, 2)
double(5)  # 10

Unlike a lambda, partial objects expose .func, .args, and .keywords, which helps debugging and some inspect tools. They are also pickleable if the underlying function is.


Important Point

Use partial for multiprocessing targets and GUI/event bindings. Beware late-binding of mutable kwargs: the same dict is reused. partialmethod exists for methods.


Follow-up Question

Q: When is a lambda clearer than partial?

Answer

When you need a tiny expression that is not just prefix arguments, or when the team finds lambda x: fn(x, flag=True) more readable. Prefer named functions if the callback is non-trivial.


32. What are structural pattern matching and when should you use match?

Interview Answer

Python 3.10 added match/case (PEP 634). It matches structure, not just equality: sequences, mappings, class patterns, guards, or-patterns, and capture names.

match command:
    case ['load', filename]:
        load(filename)
    case ['save', filename, encoding='utf-8']:
        save(filename, encoding)
    case {'op': 'ping', 'n': n} if n > 0:
        ping(n)
    case _:
        raise ValueError(command)

Class patterns use __match_args__ (dataclasses get this automatically). Matching is not the same as a chain of isinstance plus indexing, but it often replaces that boilerplate.


Important Point

Do not rewrite every if/elif. Use match for tree-shaped data (ASTs, messages, HTTP-like dicts). Remember that a bare name in a case captures, it does not compare unless you use dotted constants or a guard.


Follow-up Question

Q: Why did case 1, 2 match a 2-tuple?

Answer

Because case 1, 2: is a sequence pattern. Use case (1, 2): or case [1, 2]: for clarity. A single value uses case 1:.


33. What is the difference between an iterable, an iterator, and a generator in Python?

Interview Answer

Lists are iterable but not iterators. iter(list) is an iterator. A generator is already an iterator.

for x in xs calls iter(xs) then next until StopIteration.


Important Point

Returning a list materializes everything; returning a generator streams. APIs should document whether they return a reusable container or a one-shot iterator.


Follow-up Question

Q: Why does iterating a generator twice yield nothing the second time?

Answer

Because it is an iterator stored in one object. The second loop continues from the end. Convert to a list if you must reuse, or make a function that returns a new generator each call.


34. How does yield from work in Python?

Interview Answer

yield from subgen delegates to another iterable: it forwards yielded values, sends, throws, and retrieves the subgenerator’s return value.

def flatten(items):
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

Before yield from, people wrote manual loops and lost proper handling of .send() and .throw(). In asyncio’s older style, yield from awaited coroutines; modern code uses await.


Important Point

The expression x = yield from g captures g’s return value (StopIteration.value). That is how nested generators communicate a final result.


Follow-up Question

Q: Is yield from just a for loop?

Answer

For simple iteration over a list, yes in spirit. For generators that use send/throw/return, no — yield from implements the full generator protocol. Prefer it for composing generators.


35. What is the difference between generator expressions and list comprehensions?

Interview Answer

List/set/dict comprehensions build a complete collection in memory. A generator expression ((x*x for x in xs)) yields lazily.

Use lists when you need length, reuse, or random access. Use generators when piping large streams (files, DB cursors) into sum, any, or another consumer.

A generator expression inside sum(x for x in xs if pred(x)) avoids a temporary list. Nested comprehensions can still hide O(n²) work — readability and complexity matter more than syntax.


Important Point

Comprehensions have their own scope (the loop variable does not leak in Python 3). Generator expressions are iterators: one-shot.


Follow-up Question

Q: When is a list comprehension faster than a generator?

Answer

When the result is small and consumed multiple times, or when CPython’s list allocation beats per-item generator overhead. For a one-pass sum over millions of items, generators (or NumPy) win on memory.


36. How do you implement a custom iterator in Python?

Interview Answer

Minimum protocol:

class Countdown:
    def __init__(self, n):
        self.n = n
    def __iter__(self):
        return self
    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

Better design: the iterable is separate from the iterator so loops can restart:

class Countdown:
    def __init__(self, n): self.n = n
    def __iter__(self):
        return CountdownIterator(self.n)

In practice, a generator function is simpler and less error-prone. Custom iterator classes show up in tree walks, token streams, and when you need send-like control without generators.


Important Point

Never return None to end iteration. Always raise StopIteration. In generators, a return does that for you.


Follow-up Question

Q: Should __iter__ return self?

Answer

Only if the object is a one-shot iterator. If the type is a reusable collection, __iter__ must return a new iterator each time. Lists do this; files are closer to one-shot streams.


37. What is the difference between deque, list, and array in Python?

Interview Answer


Important Point

Using list.pop(0) in a queue is a common interview smell. Use deque. Using a list as a stack (append/pop) is idiomatic and fast.


Follow-up Question

Q: Is deque thread-safe?

Answer

Append and pop from opposite ends are atomic in CPython because of the GIL, which people abuse as a lock. That is not a documented guarantee for complex sequences of operations. Use a queue.Queue for threads.


38. How do dictionaries work internally in modern Python?

Interview Answer

CPython dicts are compact, insertion-ordered hash tables (since 3.7 insertion order is language-guaranteed). Internally there is a sparse index of hash slots and a dense array of entries (key, value, hash). That split saves memory and preserves order.

Lookup: hash the key, probe the index, compare equality on collision. Keys must be hashable and their hash must not change while in the dict.

Iteration cost is proportional to the number of live entries, not the sparse table size, which is why modern dicts iterate faster than old ones.


Important Point

Sets use a similar hash table without values. OrderedDict is still useful for move_to_end and some LRU recipes, not for basic order. Know that **kwargs order is preserved.


Follow-up Question

Q: Why can a custom object disappear from a dict?

Answer

If you mutate fields that participate in __hash__/__eq__ after insertion, the key is in the wrong bucket. That is why mutable keys are forbidden and why you freeze value objects before using them as keys.


39. What is a weakref and when would you use it in Python?

Interview Answer

A weak reference does not keep the target alive. When the last strong reference is gone, the object can be collected and the weakref becomes dead (None when called).

Tools: weakref.ref, WeakValueDictionary, WeakKeyDictionary, WeakSet, and finalize for cleanup callbacks.

Use cases: caches that should not pin objects, observer lists, intern tables, breaking cycles, and mapping from instances to extra data without storing it on the instance.


Important Point

Not all objects are weakly referenceable (some builtins, objects without __weakref__ slot). finalize is safer than __del__ for resource cleanup.


Follow-up Question

Q: How is WeakValueDictionary different from lru_cache?

Answer

LRU cache holds strong references to results (and arguments). A weak value map drops entries when the value is no longer used elsewhere. Combine them carefully; they solve different lifetime problems.


40. What are the trade-offs between namedtuple, TypedDict, and dataclass?

Interview Answer


Important Point

Do not use a dict as your internal domain model once the shape is stable. Do not use a class when you are just passing a JSON blob through a proxy.


Follow-up Question

Q: Can a dataclass be unpacked like a namedtuple?

Answer

Not by default. You can set dataclass(frozen=True) and implement __iter__, or use astuple(). If unpacking is central, NamedTuple or a tuple may still be clearer.


41. How does asyncio work in Python?

Interview Answer

asyncio is cooperative concurrency on a single thread: an event loop schedules coroutines. A coroutine yields control at await points so other tasks can run. It shines for many sockets, HTTP calls, and timers.

import asyncio

async def fetch(url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    results = await asyncio.gather(fetch(a), fetch(b))

asyncio.run(main())

Libraries must be async-aware (httpx, aiohttp, async DB drivers). A blocking call inside a coroutine stalls every task on that loop.


Important Point

Python 3.11+ TaskGroup and ExceptionGroup are the modern structured concurrency tools. asyncio.run() is the usual entry point; creating nested loops is a smell.


Follow-up Question

Q: Is asyncio faster than threads?

Answer

For high numbers of waiting I/O connections, it often uses less memory and fewer context switches. It is not faster at CPU work. Benchmarks depend on the library and whether anything blocks the loop.


42. What is the difference between async def, coroutines, and tasks?

Interview Answer

Forgetting to await is a classic bug: you get a warning about an unawaited coroutine. Creating a task without keeping a reference can cause it to be garbage-collected in some versions — store it or use a TaskGroup.


Important Point

asyncio.gather waits for many awaitables. Fire-and-forget tasks need explicit lifecycle and error handling; otherwise exceptions disappear until process shutdown.


Follow-up Question

Q: Does create_task start running immediately?

Answer

It is scheduled immediately, but it only runs when the current coroutine awaits and the loop gets control. CPU-heavy code before the first await still blocks everything.


43. What is the asyncio event loop and how do you avoid blocking it?

Interview Answer

The event loop is the scheduler: it selects ready sockets, runs callbacks, and resumes tasks. There is typically one loop per thread. Blocking the loop means no task makes progress.

Avoid:


Important Point

Debug with PYTHONASYNCIODEBUG=1, loop slow-callback warnings, and asyncio.Task.all_tasks (or asyncio.all_tasks()). In FastAPI/Starlette, sync def endpoints run in a threadpool; async def must not block.


Follow-up Question

Q: Can you run two event loops in one thread?

Answer

Not concurrently. Nested loops need hacks (nest_asyncio) and are a sign of mixing frameworks. Use asyncio.run at the edge or loop.run_until_complete only in well-defined adapters.


44. What is the difference between asyncio.gather, TaskGroup, and wait?

Interview Answer


Important Point

Prefer TaskGroup for new code: it makes lifetimes obvious and avoids “forgotten tasks.” Use gather for a fixed list of coroutines when you want a result list. Use wait when you need timeouts and partial completion.


Follow-up Question

Q: What does return_exceptions=True do?

Answer

Failed coroutines become exception objects in the result list instead of raising. You must inspect results. It does not replace proper error handling.


45. How do you mix blocking I/O with asyncio?

Interview Answer

Offload blocking calls to a thread pool:

result = await asyncio.to_thread(blocking_fn, arg)
# or
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_fn, arg)

Default executor is a thread pool. For CPU-bound work, pass a ProcessPoolExecutor. Arguments must be pickleable for processes.

Do not saturate the default pool with unbounded blocking jobs; create a dedicated executor with a limit. Watch for thread-unsafe libraries.


Important Point

The better long-term fix is an async native client. Thread offload is the adapter, not the architecture.


Follow-up Question

Q: Is to_thread the same as creating a thread per request?

Answer

No. It uses a pool. Under burst load you can still deadlock the pool if every coroutine waits on the pool and the pool workers need the loop. Keep blocking sections small and pools sized to the blocking backend.


46. What is the difference between threading.Lock, asyncio.Lock, and multiprocessing.Lock?

Interview Answer

Using a threading lock inside async code is a common deadlock: the lock holder awaits, never releasing, while another task needs the lock. Use asyncio synchronization in async code.


Important Point

RLock, Event, Condition, and Semaphore exist in both threading and asyncio flavors. Pick the one that matches the concurrency model.


Follow-up Question

Q: Does the GIL replace locks?

Answer

No. The GIL serializes bytecode, not your invariants. Compound operations (if k not in d: d[k] = ...) still race. Use locks or confine data to one thread/task.


47. What is a deadlock in Python concurrency and how do you prevent it?

Interview Answer

A deadlock is a cycle of waiters: two threads each hold a lock the other needs; a thread pool waiting on the loop that is waiting on the pool; asyncio tasks waiting on each other without a timeout.

Prevention:


Important Point

Detect with thread dumps, faulthandler, asyncio debug mode, and tracing who holds locks. In interviews, give one real example (lock order or thread pool vs loop).


Follow-up Question

Q: How is a race different from a deadlock?

Answer

A race is unsynchronized access that yields wrong results. A deadlock is progress halted. You can have races without deadlocks and vice versa.


48. How does concurrent.futures compare to asyncio?

Interview Answer

concurrent.futures is a pool of workers returning Future objects. ThreadPoolExecutor and ProcessPoolExecutor share the same API: submit, map, as_completed, shutdown.

asyncio is an event loop of coroutines. You can bridge with asyncio.wrap_future or loop.run_in_executor.

Choose futures/executors when the work is blocking or CPU-bound and the rest of the app is sync. Choose asyncio when the app is I/O concurrent end-to-end (web, websockets). Mixing is fine at the edges.


Important Point

Process pools pickle arguments and results. Lambdas and local functions often fail to pickle — use top-level functions.


Follow-up Question

Q: Can you use both in FastAPI?

Answer

Yes. FastAPI runs async routes on the loop and sync routes in a threadpool. Heavy CPU should still go to a process pool or another service so the API workers stay responsive.


49. How do you choose a concurrency strategy for CPU-bound vs I/O-bound Python?

Interview Answer

Decision sketch:

Also consider operational cost: process memory, pickle, GPU, and whether you should scale out with more containers instead of clever in-process concurrency.


Important Point

A senior answer always includes “measure”: profilers tell you if you are waiting on I/O, the GIL, or actual CPU.


Follow-up Question

Q: Why not always multiprocessing?

Answer

Memory, start-up, pickle, and harder sharing. For 10,000 idle sockets, processes are the wrong tool. For a 4-core image-processing batch, they are often the right one.


50. How would you design a high-throughput Python service given the GIL?

Interview Answer

Treat CPython as a great orchestrator, not a 32-core number cruncher in one process.

  1. Run several processes (Gunicorn/Uvicorn workers, or a process pool). Each worker has its own GIL.
  2. Keep request handlers I/O-bound; push CPU to NumPy, native extensions, or specialized workers.
  3. Use asyncio or well-sized thread pools for outbound I/O.
  4. Make work idempotent and queue-based (Redis, SQS, RabbitMQ) so you can scale consumers independently.
  5. Cache, batch, and avoid per-request Python-heavy serialization when possible.
  6. Load-test: GIL contention shows up as rising latency with extra threads inside one process, not extra processes.

Important Point

Horizontal scaling (more pods) is often cheaper than heroic in-process tricks. Shared mutable in-memory state does not scale; use Redis, DB, or sticky-free design.


Follow-up Question

Q: Would you remove the GIL (free-threaded Python)?

Answer

Python 3.13+ has experimental free-threaded builds. Mention it as future-facing: it changes extension compatibility and performance trade-offs. Production designs today still assume a GIL unless you have measured a free-threaded build with your C extensions.


51. How do you profile and optimize a slow Python application?

Interview Answer

Process:

  1. Define the SLO and a reproducible benchmark (not “it feels slow”).
  2. Measure: cProfile/pyinstrument/scalene for CPU, tracemalloc for allocations, APM (Datadog, Py-Spy) in production.
  3. Find the hot function and why: algorithm O(n²), N+1 I/O, JSON, regex, import time, GIL wait.
  4. Fix in order: algorithm and data layout, fewer allocations, batch I/O, caching, then C/NumPy/Cython.
  5. Re-measure. Guard with a benchmark in CI if it is a regression-prone path.

Important Point

Micro-optimizing loops without a profiler is how seniors waste time. Import-time slowness is its own class of bugs (python -X importtime).


Follow-up Question

Q: What is a premature optimization you refuse?

Answer

Rewriting readable Python into unreadable “faster” code that the profiler never pointed at. Also enabling __slots__ everywhere without a memory problem.


52. What is the difference between pickle, JSON, and MessagePack?

Interview Answer

Never unpickle from the network. Prefer JSON or a schema format at trust boundaries. Pickle versioning across Python versions and class moves is fragile.


Important Point

For large numeric data, consider parquet, numpy save, or shared memory instead of pickle dumps of huge graphs.


Follow-up Question

Q: Is pickle faster than JSON?

Answer

Often for Python objects, yes, but it is the wrong question for public APIs. Measure if it matters; security and interoperability usually dominate.


53. How does Python packaging work with pyproject.toml?

Interview Answer

Modern packaging (PEPs 517/518/621) uses pyproject.toml to declare the build backend (setuptools, hatchling, poetry, flit) and project metadata. The backend produces an sdist and/or a wheel.

Wheels are zip archives of compiled/pure code plus metadata. Installing a wheel skips building. Virtual environments isolate site-packages. Import packages inside the wheel must match project.packages configuration.

Lock files (uv.lock, poetry.lock, pip-tools) pin transitive versions for reproducible deploys. Applications should lock; libraries usually specify ranges.


Important Point

Editable installs (pip install -e .) symlink/import from source for development. Namespace packages and src-layout vs flat-layout are common interview follow-ups.


Follow-up Question

Q: What goes in extras vs main dependencies?

Answer

Runtime must-haves in main. Optional features (redis, dev, test) in extras so API users do not pull pytest into production.


54. What is the difference between venv, Poetry, uv, and Conda?

Interview Answer


Important Point

There is no single winner. Match the team: web services often uv/poetry/pip-tools; scientific stacks often conda or well-built wheels. Mixing conda and pip in one env is a known source of pain.


Follow-up Question

Q: Should you commit the virtualenv?

Answer

No. Commit lockfiles and pyproject.toml. Recreate the env in CI and production images.


55. How do you handle circular imports in Python?

Interview Answer

Cycles happen when module A imports B at top level and B imports A. Symptoms: AttributeError on a name that “should exist,” or ImportError: cannot import name ... (most likely due to a circular import).

Fixes, in order of cleanliness:

  1. Extract shared types/constants into a third module.
  2. Invert the dependency (the lower layer must not import the app layer).
  3. Import inside the function that needs it (lazy import).
  4. Use TYPE_CHECKING guards for type-only imports.
  5. Redesign packages so __init__.py is thin.

Important Point

Lazy imports hide the cycle and can slow first-call paths; they are acceptable at boundaries, not a substitute for architecture. Type-only cycles belong behind if TYPE_CHECKING: with from __future__ import annotations.


Follow-up Question

Q: Why do TYPE_CHECKING imports help?

Answer

They are false at runtime, so they do not execute the cycle. With postponed evaluation of annotations, hints remain strings/unevaluated and type checkers still see the types.


56. What are common Python memory leaks and how do you find them?

Interview Answer

Python “leaks” are usually unexpected references:

Find them with tracemalloc, objgraph, py-spy/memray, and by comparing heap snapshots between requests. Watch RSS vs Python allocator: native leaks will not show in gc.get_objects().


Important Point

Fix by bounding caches, using weakrefs, closing resources with context managers, and not storing request objects on singletons.


Follow-up Question

Q: Why did memory not drop after gc.collect()?

Answer

Because something still references the objects, or the memory is in native heaps, or the OS allocator did not return pages. Collection only frees unreachable Python objects.


57. How do you make Python code thread-safe?

Interview Answer

Thread safety is about invariants, not the GIL.


Important Point

Immutable data plus copying is easier to reason about than a web of locks. In web workers, prefer process isolation and a real database over in-memory shared state.


Follow-up Question

Q: Is dict assignment thread-safe?

Answer

Setting a single key is typically atomic in CPython, but check-then-act is not. Two threads can still corrupt a higher-level invariant. Treat dicts as unsafe unless locked or confined.


58. What is monkey patching and why is it risky in production?

Interview Answer

Monkey patching replaces attributes on modules or classes at runtime (tests, hotfixes, wrapping time.sleep). It is powerful and brittle.

Risks: import order, other libraries patching the same function, hidden coupling, failure in multithreaded import, and type checkers seeing the original API.

Prefer dependency injection, wrappers, and official hooks. In tests, pytest monkeypatch and unittest.mock.patch are acceptable because the lifetime is one test.

If you must patch production code, isolate it, document it, and add tests that fail when upstream changes.


Important Point

Gevent-style monkey patching of the stdlib is a special case: it can make sync code concurrent but changes the whole process. Teams should opt in globally and knowingly.


Follow-up Question

Q: How do you patch a decorated function?

Answer

Patch where it is looked up (the module that uses it), not only where it was defined, unless you patch before decoration. Patching module.fn after import of a from module import fn copy will miss the bound name.


59. How do you design testable Python code with pytest?

Interview Answer

Testable Python looks like any testable design: inject clocks, HTTP clients, and clocks; keep I/O at the edges; pure functions in the middle.

pytest specifics seniors mention:


Important Point

Avoid hitting the network in unit tests. Use responses/httpx mock transports or fake repositories. Keep a small set of integration tests against dockerized dependencies.


Follow-up Question

Q: Where do you put pytest fixtures?

Answer

Local if one module needs them; conftest.py for a directory. Autouse fixtures are easy to overuse and make tests mysterious — prefer explicit fixture arguments.


60. What are important modern Python features a senior engineer should know?

Interview Answer

A living list (3.10–3.13 era):


Important Point

You are not expected to recite PEPs. You are expected to pick a language version, know what your runtime actually is in production, and use features that reduce bugs (unions, structured concurrency, slots when they earn their keep).


Follow-up Question

Q: How do you talk about this in a .NET-heavy shop?

Answer

Map concepts: GIL vs no GIL, asyncio vs async/await, descriptors vs properties, venv vs project references. Show you can be productive in Python without abandoning senior engineering habits: testing, observability, and API design.