Chapter 6 — Advanced Python (60 Questions)
Topics (60 questions):
- 1. How does the Global Interpreter Lock (GIL) work in CPython
- 2. What is the difference between multithreading and multiprocessing in Python
- 3. How does CPython memory management work
- 4. What are reference counting and cyclic garbage collection
- 5. What is string and integer interning in Python
- 6. What is the difference between __slots__ and __dict__ in Python
- 7. How does the Python import system work
- 8. What is the difference between a module and a package in Python
- 9. How does CPython execute bytecode
- 10. What is the difference between CPython, PyPy, and Cython
- 11. What is the Python data model
- 12. What is the difference between __new__ and __init__ in Python
- 13. What are descriptors in Python
- 14. How do property, classmethod, and staticmethod work internally
- 15. What is a metaclass and when would you use one
- 16. How does method resolution order (MRO) work in Python
- 17. What is the difference between __getattr__, __getattribute__, and __setattr__
- 18. What dunder methods should you know for Python interviews
- 19. How do you implement a context manager in Python
- 20. What is the difference between __enter__/__exit__ and contextlib
- 21. What is the difference between __str__ and __repr__ in Python
- 22. What is the difference between equality and identity in Python
- 23. How do closures work in Python
- 24. How do you write a decorator that accepts arguments in Python
- 25. What is the difference between a function decorator and a class decorator
- 26. What are TypeVar, Protocol, and structural typing in Python
- 27. What is the difference between Optional, T | None, and NotRequired
- 28. How do dataclasses compare to Pydantic models
- 29. What is functools.lru_cache and when should you not use it
- 30. What is the difference between copy.copy and copy.deepcopy
- 31. How does functools.partial work
- 32. What are structural pattern matching and when should you use match
- 33. What is the difference between an iterable, an iterator, and a generator in Python
- 34. How does yield from work in Python
- 35. What is the difference between generator expressions and list comprehensions
- 36. How do you implement a custom iterator in Python
- 37. What is the difference between deque, list, and array in Python
- 38. How do dictionaries work internally in modern Python
- 39. What is a weakref and when would you use it in Python
- 40. What are the trade-offs between namedtuple, TypedDict, and dataclass
- 41. How does asyncio work in Python
- 42. What is the difference between async def, coroutines, and tasks
- 43. What is the asyncio event loop and how do you avoid blocking it
- 44. What is the difference between asyncio.gather, TaskGroup, and wait
- 45. How do you mix blocking I/O with asyncio
- 46. What is the difference between threading.Lock, asyncio.Lock, and multiprocessing.Lock
- 47. What is a deadlock in Python concurrency and how do you prevent it
- 48. How does concurrent.futures compare to asyncio
- 49. How do you choose a concurrency strategy for CPU-bound vs I/O-bound Python
- 50. How would you design a high-throughput Python service given the GIL
- 51. How do you profile and optimize a slow Python application
- 52. What is the difference between pickle, JSON, and MessagePack
- 53. How does Python packaging work with pyproject.toml
- 54. What is the difference between venv, Poetry, uv, and Conda
- 55. How do you handle circular imports in Python
- 56. What are common Python memory leaks and how do you find them
- 57. How do you make Python code thread-safe
- 58. What is monkey patching and why is it risky in production
- 59. How do you design testable Python code with pytest
- 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.
- Threading — shared memory, locks required, GIL limits CPU parallelism, best for I/O wait.
- Multiprocessing — separate processes, each with its own interpreter and GIL, true multi-core CPU work, higher memory and IPC cost.
- IPC — queues, pipes, shared memory, or managers; objects are typically pickled across process boundaries.
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:
- Reference counting — every object has an
ob_refcnt. When it hits zero, the object is deallocated immediately. - Cyclic garbage collector — finds reference cycles (lists/dicts/objects pointing at each other) that refcounting cannot free.
- Object allocators — small objects go through pymalloc arenas/pools; large objects use the system allocator.
- 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 runsThe 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.
- Small integers (implementation-defined range, commonly −5 to 256 in CPython) are cached singletons.
- Some string literals and identifiers are interned automatically.
sys.intern(s)forces string interning — useful for many repeated dict keys.
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 = yUse 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.
- Check
sys.modules— if the name is there, return that module (even if the value isNone, which can signal a failed import). - Find a loader via
sys.meta_pathfinders (builtin, frozen, path finder). - The path finder walks
sys.path/sys.path_hooks, looking for packages (__init__.pyor namespace packages) and modules. - The loader executes the module body in the module’s namespace and caches it in
sys.modulesbefore 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
- CPython — the reference interpreter, C API, widest library support, GIL, bytecode evaluator.
- PyPy — alternative interpreter with a tracing JIT. Often faster for long-running pure-Python CPU work. Weaker or slower for some C extensions (CPython C API).
- Cython — a compiler: Python-like code (optional types) to a C extension. Great for hot loops and wrapping C. You are no longer “just Python.”
- mypyc / Nuitka — other ahead-of-time approaches; mention if you have used them.
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:
__len__/__getitem__— sequences and mapping behaviour__iter__/__next__— iteration__await__/__aiter__— async__enter__/__exit__—with__bool__— truthiness
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:
- subclassing an immutable type (
int,tuple,str) — values must be set at creation - implementing a singleton or interned instance cache
- returning an instance of a different class (factory-like metaclasses, ORMs)
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] = valueData 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:
property— data descriptor;__get__callsfget(instance);__set__callsfset.- Functions — non-data descriptors;
__get__(instance, owner)returns a bound method. classmethod—__get__binds the class (or the instance’s class), not the instance.staticmethod—__get__returns the raw function, unbound.
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] = clsYou almost never need a metaclass. Prefer:
- decorators on classes
__init_subclass__(PEP 487) for subclass hooks- descriptors for attribute behaviour
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
__getattribute__— called for every attribute access. Easy to recurse forever if you writeself.fooinside it. Useobject.__getattribute__(self, name)orsuper().__getattr__— called only when normal lookup failed (not found on class/instance). Good for lazy attributes and proxies.__setattr__— called for every assignment. Same recursion trap. Set viaobject.__setattr__orself.__dict__[name] = valuewhen there is a dict.__delattr__— deletion analog.
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:
- Construction:
__new__,__init__,__del__(know why__del__is unreliable) - Representation:
__repr__,__str__,__format__ - Comparisons:
__eq__,__lt__,__hash__(hash/eq contract) - Containers:
__len__,__getitem__,__setitem__,__contains__,__iter__ - Callables:
__call__ - Context:
__enter__,__exit__, async variants - Numeric:
__add__,__radd__,__iadd__ - Copy/pickle:
__copy__,__deepcopy__,__getstate__
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 FalseGenerator 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:
@contextmanager— write a generator instead of a class@asynccontextmanager— async variantclosing/suppress/ExitStack/nullcontextExitStack— dynamically push many managers (especially when N is not known at compile time)
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) # 7Late binding is the classic trap in loops:
funcs = [lambda: i for i in range(3)]
funcs[0]() # 2, not 0All 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 decoAlways 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).
TypeVar— generic parameters:T = TypeVar('T'), bounded or constrained.Generic[T]— generic classes; 3.12+ alsoclass Repo[T]:Protocol— structural typing (“has these methods”), like a Go interface.@runtime_checkableenablesisinstancewith limits.TypedDict— dicts with known keys for JSON-shaped data.
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
Optional[T]isUnion[T, None]— the value may beNone.- In 3.10+,
T | Noneis the preferred spelling. NotRequired[K](TypedDict) means the key may be absent, which is not the same as the key present with valueNone.Required[K]forces a key in a TypedDict that is otherwise total=False.
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:
- arguments include mutable or unhashable objects (lists, dicts) unless you convert to tuples
- results depend on time, I/O, or request context
- the key space is unbounded user input (memory leak)
- the function has side effects you still need on every call
- you need cache invalidation richer than
cache_clear()
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) # 10Unlike 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
- Iterable — has
__iter__returning an iterator (or a sequence with__getitem__from 0). Can be iterated many times if eachiter()produces a fresh iterator. - Iterator — has
__next__and__iter__returningself. One-shot: exhausted after use. - Generator — an iterator created by a function with
yield, or a generator expression. Lazy, stateful, cheap to start.
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 itemBefore 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 + 1Better 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
list— dynamic array of pointers. O(1) append/pop on the right, O(n) insert/pop on the left, excellent random access.collections.deque— double-ended queue, O(1) append/pop both ends, poor random access. Optionalmaxlenfor ring buffers.array.array— compact C-typed numeric storage, still Python objects when you iterate.memoryview/bytes/bytearray— binary buffers.- NumPy ndarray — true contiguous numeric arrays and vectorization; different ecosystem.
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
namedtuple— immutable, unpackable, tuple-compatible, light. Awkward to extend, still a tuple (accidental iteration/compare). Prefertyping.NamedTuplefor annotations.TypedDict— types adictfor JSON/HTTP. No runtime validation by itself. Keys are strings. Good at boundaries.dataclass— real class, optional mutability/frozen/slots, methods, inheritance. Best default for domain objects.pydantic.BaseModel— when you need validation/coercion.
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
async defdefines a coroutine function. Calling it returns a coroutine object; it does not run until awaited or scheduled.- A coroutine object is awaitable.
await crruns it in the current task. - A Task (
asyncio.create_task) schedules the coroutine on the loop so it can run concurrently with the current coroutine.
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:
time.sleep— useawait asyncio.sleep- sync HTTP/DB clients — use async drivers or
run_in_executor - heavy CPU in a coroutine — process pool or native code that releases the GIL
- holding a lock while awaiting (deadlock / stall depending on lock type)
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
gather(*aws, return_exceptions=False)— wait for all; first exception cancels the others by default in recent Python when used with tasks, and raises that exception. Order of results matches inputs.asyncio.wait— lower level; returns done/pending sets; you chooseFIRST_COMPLETEDetc.asyncio.TaskGroup(3.11) — structured concurrency: leaving theasync withwaits for children; failures raiseExceptionGroupand cancel siblings.asyncio.as_completed— yield as each finishes.
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
threading.Lock— mutual exclusion for threads in one process. Blocking acquire blocks the OS thread. Do not hold it acrossawaitin asyncio code running on that thread.asyncio.Lock— mutex for tasks on the same loop.await lock.acquire()yields; other tasks can run. Useless across threads unless you are very careful.multiprocessing.Lock— IPC mutex for processes, implemented with OS primitives. Heavier.
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:
- lock ordering (always acquire A then B)
- timeouts and cancellation
- avoid lock-and-await with thread locks
- do not call loop.run_until_complete from a running loop
- bounded queues so producers cannot fill forever while consumers need the same lock
- prefer message passing over shared mutable state
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:
- I/O-bound, few connections, sync libraries — threads or a thread pool.
- I/O-bound, many connections, async libraries — asyncio.
- CPU-bound Python — multiprocessing, or rewrite hot path (Cython/NumPy/another language).
- CPU-bound in C that releases the GIL — threads can actually parallelize.
- Mixed — asyncio +
to_thread/ process pool for the blocking islands.
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.
- Run several processes (Gunicorn/Uvicorn workers, or a process pool). Each worker has its own GIL.
- Keep request handlers I/O-bound; push CPU to NumPy, native extensions, or specialized workers.
- Use asyncio or well-sized thread pools for outbound I/O.
- Make work idempotent and queue-based (Redis, SQS, RabbitMQ) so you can scale consumers independently.
- Cache, batch, and avoid per-request Python-heavy serialization when possible.
- 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:
- Define the SLO and a reproducible benchmark (not “it feels slow”).
- Measure:
cProfile/pyinstrument/scalenefor CPU,tracemallocfor allocations, APM (Datadog, Py-Spy) in production. - Find the hot function and why: algorithm O(n²), N+1 I/O, JSON, regex, import time, GIL wait.
- Fix in order: algorithm and data layout, fewer allocations, batch I/O, caching, then C/NumPy/Cython.
- 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
- JSON — text, interoperable, human-readable, no arbitrary Python objects, no code execution. Default for APIs.
- pickle — Python-specific, can serialize many objects, unsafe to load untrusted data (arbitrary code). Fine for trusted caches and some multiprocessing.
- MessagePack / protobuf / Avro — compact binary, schema or schema-like, good for queues and services.
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
venv/virtualenv— environment isolation only. Pair with pip and a lock tool.- pip + pip-tools/uv pip compile — classic PyPI workflow.
- Poetry — dependency resolver, lockfile, build, venv management.
- uv — very fast installer/resolver from Astral; can replace pip/venv/pip-tools in many workflows.
- Conda/mamba — language-agnostic binaries (Python + C libs + non-Python). Common in data science when wheels are painful.
- pipx — install CLI tools in isolated envs.
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:
- Extract shared types/constants into a third module.
- Invert the dependency (the lower layer must not import the app layer).
- Import inside the function that needs it (lazy import).
- Use TYPE_CHECKING guards for type-only imports.
- Redesign packages so
__init__.pyis 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:
- unbounded caches and global lists
- lru_cache on functions with unique arguments per request
- default mutable arguments accumulating data
- lingering tracebacks (
sys.exc_infocycles in old code) - unclosed generators holding frames
- C extensions and native buffers
- listeners/callbacks never unsubscribed
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.
- Prefer isolation: one thread owns the data, others send messages (
queue.Queue). - Use locks around compound updates; keep critical sections tiny.
- Know which stdlib types have atomic operations in CPython (simple list.append) and do not rely on that in portable designs.
- Do not share non-thread-safe connections (many DB clients, some RNG objects).
- For asyncio, do not share thread locks across await; use asyncio primitives.
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:
- fixtures for setup, composition, and teardown (
yieldfixtures) monkeypatchandtmp_pathinstead of real home directories- parametrize for table-driven tests
- markers for slow/integration tests
- assert rewriting — no need for
self.assertEqual - hypothesis for property-based tests on parsers and codecs
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):
match/case(3.10)- parenthesized context managers,
X | Yunions,int | None(3.10) - Exception groups and
except*(3.11), TaskGroup, faster CPython 3.11+ - dataclass
slots=True,Selftype,tomllib(3.11) - f-string improvements,
typestatements and PEP 695 generics (3.12) - free-threaded experiment, defined
locals()semantics, more stdlib (3.13) uv/ modern packaging,pyproject.tomleverywhere- typing: Protocol, TypedDict, ParamSpec, TypeAlias
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.