32 Senior Python Interview Questions and Answers (2026)

Python is the most popular programming language in the world. It is the backbone of most AI backends, ML systems, and big data pipelines. Knowing it has become almost expected in 2026. But most engineers do not know it well.
These senior Python interview questions are what can set you apart from other candidates and land your next 6-figure role.
Q1.What happens at the bytecode level when a Python script is executed?
The interpreter first compiles your source into a code object containing bytecode, then a stack-based evaluation loop executes those opcodes one by one against an in-memory value stack and frames.
Parse and compile: Source is tokenized, parsed into an AST, then compiled into a code object (holds bytecode, constants, names).
Bytecode instructions: Each opcode (e.g. LOAD_FAST, BINARY_OP, CALL) manipulates a value stack.
Execution via frames: Each function call creates a frame object holding its locals, the stack, and instruction pointer.
Inspect it yourself: Use the dis module to see the actual bytecode for any function.
Q2.How does the 'Specializing Adaptive Interpreter' introduced in 3.11/3.12 improve performance?
The Specializing Adaptive Interpreter (PEP 659) watches which operations run in a hot loop and rewrites generic bytecode into faster, type-specialized variants at runtime, avoiding repeated type checks and lookups.
Adaptive specialization: A generic opcode like BINARY_OP is replaced by a specialized form (e.g. integer-add) once the interpreter observes the operands are always the same type.
Quickening: Hot bytecode is swapped in-place for optimized variants after a warm-up threshold of executions.
Inline caching: Results of expensive lookups (attributes, globals, method calls) are cached on the instruction so repeats skip the full search.
Graceful deoptimization: If an assumption breaks (a different type appears), it falls back to the generic opcode, so behavior is always correct.
Net effect: meaningful speedups (the 3.11 "faster CPython" project) with no code changes required.
Q3.What is string and integer interning in Python, and why does a=256;b=256;a is b return True while a=257;b=257;a is b might return False?
a=256;b=256;a is b return True while a=257;b=257;a is b might return False?Interning means Python reuses a single cached object for certain immutable values, so identity (is) comparisons return True. CPython pre-creates small integers from -5 to 256, so two variables holding 256 point to the same object; 257 is created fresh each time, so they may be distinct objects.
Integer interning (small int cache):
CPython caches integers in the range [-5, 256] at startup; any reference to those values reuses the cached object.
Outside that range each literal creates a new object, so a is b can be False even though a == b is True.
String interning:
String literals that look like identifiers (letters, digits, underscores) are often interned automatically at compile time.
You can force it with sys.intern() to speed up repeated equality checks (dict keys, parsers).
Key takeaway:
Interning is a CPython implementation detail for memory and speed, not a language guarantee.
Use == for value equality and reserve is for identity (e.g. is None).
Q4.Beyond mutability, what are the architectural differences between a list and a tuple, and why is a tuple slightly more memory-efficient?
Beyond a tuple being immutable and a list mutable, the difference is structural: a list is a variable-length container that over-allocates room to grow, while a tuple is fixed-size and stored more compactly, making it lighter and enabling extra optimizations.
Lists over-allocate: A list keeps spare capacity so append is amortized O(1); it stores a pointer array plus length and allocation size.
Tuples are fixed-size: Since size never changes, there's no growth buffer and less bookkeeping, so a tuple uses fewer bytes than a list of the same elements.
Optimizations tuples enable:
CPython can cache (free-list) small tuples for reuse and embed constant tuples directly in compiled bytecode.
Immutability lets a tuple be hashable, so it can be a dict key or set member.
Intent signaling: Tuples convey a fixed, heterogeneous record; lists convey a homogeneous, growable sequence.
Q5.When would you use __slots__ in a class definition, and what are the trade-offs regarding memory and flexibility?
__slots__ in a class definition, and what are the trade-offs regarding memory and flexibility?Use __slots__ to declare a fixed set of attributes, which removes each instance's per-object __dict__ and saves significant memory when you create many instances. The trade-off is lost flexibility.
How it works: __slots__ = ('x', 'y') stores attributes in a compact fixed layout instead of a dynamic dict, cutting memory and slightly speeding attribute access.
When to use it: Classes instantiated in large numbers (millions of small objects) where memory is the bottleneck.
Trade-offs / limits:
You can't add new attributes not listed in __slots__ (raises AttributeError).
No __dict__ means some dynamic patterns and tools that expect it break unless you add '__dict__' to slots.
Inheritance is subtle: a subclass without its own __slots__ regains a __dict__, negating savings.
Q6.Explain the difference between __init__ and __new__. When would you specifically need to override __new__?
__init__ and __new__. When would you specifically need to override __new__?__new__ creates and returns the instance; __init__ initializes the already-created instance. __new__ is a static method that runs first and controls object creation, while __init__ just sets attributes on it.
__new__:
Receives the class cls, allocates the object (usually via super().__new__(cls)), and returns it.
If it doesn't return an instance of cls, __init__ is not called.
__init__: Receives the new self, returns None, and only configures state.
When you actually need __new__:
Subclassing immutable types like int, str, or tuple, whose value must be set at creation.
Implementing singletons or instance caching/interning.
Returning an instance of a different class (factory behavior).
Q7.Explain the difference between __getattr__ and __getattribute__.
__getattr__ and __getattribute__.Both customize attribute access, but __getattribute__ is called on every attribute lookup, while __getattr__ is a fallback called only when normal lookup fails.
__getattribute__ is the primary hook:
Invoked unconditionally for every attribute access (even ones that exist).
Easy to cause infinite recursion: use super().__getattribute__(name) instead of self.x inside it.
__getattr__ is the fallback:
Called only when the normal mechanism raises AttributeError (attribute not found).
Ideal for lazy attributes, proxies, or dynamic defaults.
Order of resolution:
__getattribute__ runs first and does the real lookup.
If it raises AttributeError, __getattr__ is tried as a backup.
Q8.How does Python handle multiple inheritance? Explain the C3 Linearization algorithm and how super() determines which class to call next.
super() determines which class to call next.Python resolves multiple inheritance using the Method Resolution Order (MRO), computed by the C3 linearization algorithm, and super() walks that MRO rather than jumping to a fixed parent.
What the MRO guarantees:
A consistent, deterministic ordering where each class appears once.
A subclass always precedes its parents, and the order of bases is preserved.
C3 linearization (how it's built):
The MRO of a class is the class itself followed by a merge of the MROs of its parents plus the list of parents.
The merge takes the head of a list only if that head appears nowhere in the tail of another list, otherwise moves on.
If no consistent order exists, Python raises a TypeError.
How super() uses it:
super() calls the next class after the current one in the instance's MRO, not necessarily the literal base class.
This enables cooperative multiple inheritance: each class calls super() so every class in the chain runs exactly once (the diamond problem solved).
Inspect it via ClassName.__mro__ or ClassName.mro().
Q9.Explain how the Descriptor protocol works. How do @property, @classmethod, and @staticmethod use descriptors under the hood?
@property, @classmethod, and @staticmethod use descriptors under the hood?A descriptor is an object that defines attribute-access behavior via __get__, __set__, or __delete__; when stored as a class attribute it intercepts access to that attribute on instances.
The protocol:
Data descriptor: defines __set__ (or __delete__); takes priority over the instance __dict__.
Non-data descriptor: defines only __get__; the instance dict overrides it.
Lookup order: data descriptor, then instance dict, then non-data descriptor.
How the built-ins use it:
@property is a data descriptor: __get__ calls your getter, __set__ the setter.
@staticmethod is a non-data descriptor whose __get__ returns the underlying function unchanged.
@classmethod is a non-data descriptor whose __get__ binds the class as the first argument.
Plain functions are non-data descriptors too: their __get__ produces bound methods.
Why it matters: Lets you centralize validation, computed values, and lazy loading in reusable attribute objects.
Q10.What is a metaclass, and how does it differ from a standard class? When would you use a metaclass instead of a class decorator?
A metaclass is the "class of a class": it controls how classes themselves are created, just as a class controls how instances are created. The default metaclass is type.
How it differs from a normal class:
A class produces instances; a metaclass produces classes.
Defining class Foo(metaclass=Meta) calls Meta(name, bases, namespace) at class-creation time.
Override __new__ or __init__ on the metaclass to inspect or rewrite the class body.
When to use a metaclass:
You must affect class creation itself: enforcing rules across all subclasses, auto-registering subclasses, or injecting members before the class exists.
Behavior should propagate automatically to every subclass.
When a class decorator is enough (usually preferred):
You only need to modify or wrap a class after it's built; simpler and more explicit.
Decorators don't propagate to subclasses and don't compose into the metaclass hierarchy.
Modern note: __init_subclass__ and __set_name__ cover many cases that once required metaclasses, with far less complexity.
Q11.When would you use __init_subclass__ instead of a metaclass?
__init_subclass__ instead of a metaclass?Use __init_subclass__ when you only need to customize or validate subclasses as they're defined: it's a simpler, more readable hook than a full metaclass, which you reserve for changing class creation itself (controlling the namespace, type mechanics, or instance creation).
__init_subclass__ is a classmethod called on the parent each time a subclass is created:
Great for registering subclasses, validating required attributes, or injecting defaults.
No new metaclass means no metaclass-conflict problems in multiple inheritance.
Reach for a metaclass only when you need more power:
Customizing the class body namespace via __prepare__, or overriding __new__/__call__ of the class.
Controlling how instances are created or how the class object itself behaves.
Rule of thumb: If the goal is "do something each time someone subclasses me," prefer __init_subclass__; metaclasses are heavier and harder to compose.
Q12.Explain the difference between Nominal typing and Structural typing (Protocols) in Python.
Protocols) in Python.Nominal typing asks "does this class explicitly inherit from / declare that type?" while structural typing (via Protocol) asks "does this object have the right shape (methods/attributes)?" regardless of its ancestry. Python's type system supports both.
Nominal typing:
Compatibility comes from the declared name in the hierarchy: a value satisfies Animal only if its class subclasses Animal.
This is how normal classes and ABCs (without register) work.
Structural typing (Protocols):
A class matches a Protocol if it has the required members, even with no inheritance link, the static equivalent of duck typing.
Decorate with @runtime_checkable to allow isinstance() checks (members only, not signatures).
Why it matters: Protocols let you type third-party or unrelated classes you can't modify, decoupling code from concrete hierarchies.
Q13.What is the difference between typing.Any and object in a type hint?
typing.Any and object in a type hint?Both accept any value, but they're opposites to a type checker: Any disables type checking on that value, while object is the strict common supertype that lets you store anything but lets you do almost nothing without narrowing first.
typing.Any is an escape hatch:
Any operation, attribute access, or call on it is allowed and unchecked.
It is both assignable to and from every type, effectively turning off the checker, use sparingly.
object is the safe top type:
Everything is an object, but the checker only permits operations valid on object itself (e.g. str(), ==).
To use it as something specific you must narrow with isinstance() or a cast.
Rule of thumb: Use object when you truly accept anything but want safety; use Any only when you deliberately need to bypass checking.
Q14.Explain the new Type Parameter Syntax introduced in PEP 695 (Python 3.12).
PEP 695 (Python 3.12).PEP 695 (Python 3.12) adds inline syntax for declaring type parameters directly on functions, classes, and type aliases, removing the need to manually create TypeVar objects and the old Generic base class.
Old way: Explicitly declare T = TypeVar("T") and inherit from Generic[T], verbose and easy to get scoping wrong.
New inline syntax:
Write the parameter in square brackets after the name: def first[T](xs: list[T]) -> T or class Stack[T]:.
New type statement for aliases: type Vector[T] = list[T].
Supports bounds and constraints, e.g. [T: int] (upper bound), plus *Ts for TypeVarTuples and **P for ParamSpec.
Benefits:
The parameters are lexically scoped to the construct, so no accidental sharing of a module-level TypeVar.
Type aliases via type are lazily evaluated, helping with forward references.
Q15.How does Python's memory management work? Explain the difference between reference counting and the cyclic garbage collector, and how Python handles two objects that reference each other.
CPython manages memory primarily with reference counting: every object tracks how many references point to it, and it is freed instantly when that count hits zero. Because reference counting alone cannot reclaim reference cycles, a separate generational cyclic garbage collector periodically finds and frees groups of objects that reference each other but are otherwise unreachable.
Reference counting (the primary mechanism):
Each object has a counter incremented when a new reference is made and decremented when one goes away.
At zero, the object is deallocated immediately (deterministic, prompt).
Weakness: two objects referencing each other never reach zero, even with no outside references.
The cyclic garbage collector (the backup):
Tracks container objects (lists, dicts, instances) that can form cycles.
Periodically detects groups whose only references are internal to the group and collects them.
Exposed via the gc module (you can disable, tune, or force it with gc.collect()).
Two objects referencing each other:
Reference counts stay at 1 even after you drop your variables, so refcounting can't free them.
The cyclic GC identifies the unreachable cycle and reclaims both objects.
Note: objects with __del__ in cycles used to be uncollectable; modern Python handles most of these.
Q16.What is the difference between the stack and the heap in the context of Python's memory management, and who manages the private heap?
The call stack holds frames for function calls (local names, return addresses) and is managed automatically by the interpreter as functions enter and exit. All actual Python objects live on a private heap, which is managed entirely by the CPython memory manager, not by your code directly.
The stack:
Stores frame objects for active calls: local variable names, parameters, bookkeeping.
Those locals are references (pointers) to objects, not the objects themselves.
Grows and shrinks automatically with call depth; deep recursion raises RecursionError.
The private heap:
All Python objects and data structures (ints, lists, instances) reside here.
You never allocate/free it manually; you only create references.
Who manages the heap:
The CPython memory manager owns the private heap, layered: raw OS allocator, then pymalloc (an arena/pool allocator for small objects), then object-specific allocators.
Reclamation is driven by reference counting plus the cyclic GC, so the programmer doesn't free memory explicitly.
Q17.What are 'Immortal Objects' (PEP 683) and how do they help with multi-processing performance?
PEP 683) and how do they help with multi-processing performance?Immortal objects (PEP 683) are objects whose reference count is fixed and never changes, so they are never deallocated for the life of the interpreter. This matters for performance because mutating a refcount writes to the object's memory, which is hostile to sharing memory pages across processes.
What they are:
Long-lived singletons like None, True, False, small ints, and interned strings are marked immortal.
Their refcount is pinned to a sentinel value; incref/decref become no-ops on them.
Why it helps copy-on-write / multiprocessing:
When you fork, child processes share parent memory pages via copy-on-write.
Normally, just reading a shared object still changes its refcount, dirtying the page and forcing a private copy.
Immortal objects never touch their refcount, so shared pages stay clean and truly shared, cutting memory use.
Broader payoff:
Stable, never-changing objects are also safer to share across threads, supporting later free-threading work.
Trade-off: a small per-object check and these objects are intentionally never freed.
Q18.Why does Python not immediately release memory back to the operating system when an object is deleted?
Deleting an object drops its reference count and may free it inside CPython, but that freed space usually returns to Python's own allocator pools rather than to the OS. Python keeps the memory around to reuse for future allocations because returning it is often impossible or wasteful.
Allocator pooling:
Small objects are served by pymalloc from arenas/pools; freed blocks go back to the pool, ready for reuse.
This avoids costly syscalls on every allocate/free and reduces fragmentation churn.
Arenas free only when fully empty:
An entire arena is returned to the OS only when every object in it is freed.
One surviving object keeps the whole arena alive (fragmentation), so memory often stays resident.
Practical implications:
A program that briefly allocates a lot may keep a high resident size even after freeing.
To truly release memory, isolate big workloads in a separate process that you then terminate.
Q19.How does generational garbage collection work in Python, and why does the collector move objects between 'Generation 0', '1', and '2'?
Generational GC is based on the empirical observation that most objects die young. The cyclic collector groups tracked objects into three generations and scans the youngest most often; objects that survive a collection are promoted to an older, less-frequently-scanned generation, concentrating effort where garbage is most likely.
The generational hypothesis:
Newly created objects are most likely to become garbage soon, so scan them often.
Long-lived objects rarely become garbage, so scanning them repeatedly wastes time.
The three generations:
Gen 0: brand-new objects; collected most frequently.
Gen 1 and Gen 2: progressively older survivors, collected less often.
Each generation has a threshold; exceeding it triggers a collection of that generation and younger ones.
Why objects move between generations:
Surviving a collection is evidence an object is longer-lived, so it is promoted to the next generation.
Promotion reduces how often stable objects are re-examined, lowering overall GC cost.
Scope note:
This applies only to the cyclic collector for container objects; immediate frees still happen via reference counting.
Thresholds are tunable via gc.set_threshold().
Q20.What is the difference between a Coroutine and a standard Generator?
Both are built on the same suspend/resume machinery, but a generator produces a stream of values (you pull from it), while a coroutine consumes values and orchestrates async work (you push into it or await it). Modern coroutines are defined with async def and driven by an event loop.
Generator: a data producer: Uses yield to emit values; driven by next() or a for loop.
Coroutine: a cooperative task:
Classic coroutines receive data via send() and pause at yield.
Native coroutines (async def) use await to suspend on I/O; an event loop schedules them.
Shared core: both pause and resume while preserving local state; the difference is direction of data and who drives them.
Q21.What do coroutines and generators have in common, and how did generators evolve into async/await?
async/await?Both rely on the same suspendable-function mechanism: a frame that can pause at yield and resume later with its state intact. Python's async syntax evolved directly out of generators, with async/await eventually becoming distinct, dedicated syntax.
Common ground: Suspend and resume execution while preserving locals, instruction pointer, and the call frame.
The evolution path:
PEP 342 added send()/throw(), turning generators into two-way coroutines.
PEP 380 added yield from for delegation, enabling layered coroutines.
Early asyncio used @asyncio.coroutine with yield from.
PEP 492 introduced async def and await as native syntax, separating coroutines from generators.
Result: native coroutines are their own type, but the underlying pause/resume engine is shared with generators.
Q22.What do the send(), throw(), and close() methods do on a generator object?
send(), throw(), and close() methods do on a generator object?These methods let you interact with a paused generator beyond just pulling values: send() resumes it while passing a value in, throw() raises an exception at the suspension point, and close() stops it permanently.
send(value):
Resumes the generator, making the paused yield expression evaluate to value, and returns the next yielded value.
First call must be send(None) (or next()) to prime it.
throw(exc): Raises exc inside the generator at the current yield, letting it catch/clean up.
close(): Raises GeneratorExit at the yield; the generator should finish (often via try/finally) and not yield again.
Q23.What are positional-only and keyword-only parameters, and how do the / and * markers in a function signature work?
/ and * markers in a function signature work?Positional-only parameters can only be passed by position, and keyword-only parameters can only be passed by name. The / and * markers in a signature draw the boundaries between these zones.
The / marker:
Everything before / is positional-only and cannot be passed as a keyword.
Lets you rename parameters freely later and prevents callers from depending on internal names.
The * marker:
Everything after a bare * is keyword-only and must be passed by name.
Forces explicit, self-documenting calls (good for booleans/flags).
The middle zone: Parameters between / and * can be passed either positionally or by keyword.
Q24.How do you implement an asynchronous context manager (async with), and how do __aenter__ and __aexit__ differ from their synchronous counterparts?
async with), and how do __aenter__ and __aexit__ differ from their synchronous counterparts?An asynchronous context manager works with async with and defines __aenter__ and __aexit__ as coroutines, so its setup and teardown can await I/O. They mirror the sync versions but are awaited by the runtime.
The protocol:
__aenter__ is awaited on entry and its result is bound by as; __aexit__(exc_type, exc, tb) is awaited on exit, always.
Only usable inside an async def; using a plain with on it raises TypeError.
Difference from sync: The methods are coroutines, letting you await connection setup or async cleanup (e.g. closing a pool) without blocking the event loop.
Easier construction: Use @asynccontextmanager with an async generator: code before yield is async setup, after is async teardown.
Q25.What is the experimental 'free-threaded' build in Python 3.13 (PEP 703), and what are the trade-offs of removing the GIL for existing C extensions?
PEP 703), and what are the trade-offs of removing the GIL for existing C extensions?Q26.Compare and contrast threading, multiprocessing, and asyncio. When is each appropriate?
threading, multiprocessing, and asyncio. When is each appropriate?Q27.Explain what the GIL is and how it impacts multithreading in Python. If Python has a GIL, when would you still choose to use the threading module over multiprocessing?
GIL is and how it impacts multithreading in Python. If Python has a GIL, when would you still choose to use the threading module over multiprocessing?Q28.What is the difference between a per-interpreter GIL introduced in 3.12 and the traditional GIL?
GIL introduced in 3.12 and the traditional GIL?Q29.How does the asyncio event loop work, and what happens to the loop when you execute a blocking I/O call inside an async function?
asyncio event loop work, and what happens to the loop when you execute a blocking I/O call inside an async function?Q30.What makes an object awaitable in Python? Explain the relationship between coroutines, Tasks, and Futures.
awaitable in Python? Explain the relationship between coroutines, Tasks, and Futures.Q31.How does the await keyword actually work under the hood?
await keyword actually work under the hood?Q32.How does the Python import system work, and what happens when you import a module for the second time?
import system work, and what happens when you import a module for the second time?