Python Interview Questions & In-Depth Solutions
Curated by Vinod Kumar Kayartaya (30+ Years Experience as Software Trainer, Senior Developer & AI Architect). High-impact questions covering CPython internals, memory management, decorators, OOP, and asynchronous architectures.
Members-Only Access
This comprehensive Python interview guide is exclusively available to authenticated members. Please sign in with your Google account to unlock full questions, code examples, and architectural explanations.
1. How does memory management and garbage collection work in CPython?
CPython manages memory primarily through Reference Counting, complemented by a Generational Cyclic Garbage Collector.
- Reference Counting: Every Python object has an internal counter (
ob_refcntin C). Whenever an object is assigned, passed to a function, or stored in a list, its ref count increments. When references go out of scope or are deleted withdel, the count decrements. Once it reaches 0, the memory is deallocated immediately. - Cyclic Garbage Collector: Reference counting cannot collect cyclic references (e.g., Object A references Object B, and Object B references Object A). CPython solves this using a 3-generation cyclic garbage collector (Generation 0, 1, and 2). Generation 0 contains recently created objects and is inspected most frequently; surviving objects are promoted to Generation 1 and 2.
import sys
import gc
a = []
print("Initial ref count:", sys.getrefcount(a)) # 2 (a + argument to getrefcount)
b = [a]
print("After referencing:", sys.getrefcount(a)) # 3
# Creating a reference cycle:
class Node:
def __init__(self):
self.cycle = self
n = Node()
del n # Ref count never hits 0 due to self-reference!
print("Uncollected before gc:", gc.collect()) # Collected by cyclic GCsys.getrefcount(), the fact that reference count cleanup is instantaneous and deterministic, and why the gc module is only needed for reference cycles.2. What is the Global Interpreter Lock (GIL) and what is its status in Python 3.13?
The GIL (Global Interpreter Lock) is a mutual-exclusion lock used by CPython to prevent multiple native OS threads from executing Python bytecodes concurrently. It exists primarily because CPython's memory management is not thread-safe.
- I/O-Bound Tasks: Threads are very effective because the GIL is released during socket/file I/O operations and sleeping (
time.sleep()). - CPU-Bound Tasks: Multiple threads cannot run on multiple CPU cores in parallel because only one thread holds the GIL at any instant. Use the
multiprocessingmodule or C extensions (like NumPy) that release the GIL. - Python 3.13 Update (PEP 703): Python 3.13 introduces experimental free-threaded mode (disabling the GIL), allowing true multi-core thread execution without the GIL.
import sys
# Checking if running on a free-threaded Python build (Python 3.13+):
is_gil_enabled = getattr(sys, "_is_gil_enabled", None)
if is_gil_enabled is not None:
print("GIL enabled:", is_gil_enabled())
else:
print("Standard CPython with GIL active")threading/asyncio works great) and CPU-bound tasks (where multiprocessing is needed), and cite PEP 703 for modern bonus points.3. What is the difference between `is` and `==`? Explain Integer Interning.
== checks for equality of value (by calling __eq__()), whereas is checks for identity of object in memory (by comparing id(a) == id(b)).
Small Integer Interning: CPython pre-allocates an array of integer objects for values between -5 and 256 inclusive. Any integer in this range reuses the exact same object in memory.
x = 256
y = 256
print(x == y) # True (same value)
print(x is y) # True (interned small integer in [-5, 256])
a = 1000
b = 1000
print(a == b) # True
print(a is b) # False (different objects in memory when evaluated separately)is for comparison of values like numbers or strings, except when checking singleton objects like var is None or var is True.4. Deep Copy vs Shallow Copy: What are the exact differences and performance costs?
When working with compound objects containing references to other objects (e.g. lists of dictionaries):
- Shallow Copy (
copy.copy(),list.copy(),list[:]): Constructs a new collection object, but inserts references into it to the objects found in the original. If a nested mutable element is modified, both copies reflect the change. - Deep Copy (
copy.deepcopy()): Recursively copies every object and child object found in the source, creating a completely independent object graph. It handles cyclical graphs using an internal memo dictionary.
import copy
orig = [[1, 2, 3], [4, 5, 6]]
shallow = copy.copy(orig)
deep = copy.deepcopy(orig)
orig[0].append(99)
print("Shallow affected:", shallow) # [[1, 2, 3, 99], [4, 5, 6]]
print("Deep unaffected:", deep) # [[1, 2, 3], [4, 5, 6]]5. How do Python Decorators work? How do you write a decorator with arguments and preserve metadata?
A decorator is a higher-order function that takes a callable as an argument, extends or alters its behavior, and returns a new callable. The @decorator syntax is syntactic sugar for func = decorator(func).
To preserve function introspection (such as __name__ and __doc__), always decorate the wrapper with @functools.wraps(func).
import time
from functools import wraps
def retry(max_attempts=3, delay_seconds=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
time.sleep(delay_seconds)
return wrapper
return decorator
@retry(max_attempts=3, delay_seconds=0.5)
def unstable_network_call():
"""Fetches critical data from payment gateway."""
return "Success"
print(unstable_network_call.__name__) # "unstable_network_call" (preserved by @wraps)6. What are Generators and `yield`? How do they differ from normal functions and iterators?
A generator function uses the yield keyword instead of return. When called, it does not execute the function body immediately; instead, it returns a generator object that conforms to Python's iterator protocol (__iter__() and __next__()).
- Lazy Evaluation: Values are computed on-demand one at a time, keeping memory complexity at \(O(1)\) even when processing gigabytes of log files.
- State Preservation: When a generator yields, its local execution frame (local variables, instruction pointer) is frozen until
next()is called again. - Bidirectional Communication: Generators can also receive data at runtime using the
.send(value)method.
# Generator expression vs List comprehension:
import sys
# 10 Million numbers
list_comp = [i for i in range(10_000_000)] # Consumes ~800MB RAM
gen_exp = (i for i in range(10_000_000)) # Consumes ~120 Bytes!
print("List RAM:", sys.getsizeof(list_comp), "bytes")
print("Gen RAM: ", sys.getsizeof(gen_exp), "bytes")7. How does Python's Context Manager protocol work? Explain `__enter__` and `__exit__`.
Context managers provide deterministic resource management (closing files, releasing locks, database transaction rollback) using the with statement.
__enter__(self): Prepares the resource. The return value is bound to the variable afteras.__exit__(self, exc_type, exc_val, exc_tb): Guarantees teardown. If an exception occurred inside thewithblock, its type, value, and traceback are passed. ReturningTruesuppresses the exception; returningFalse(or None) allows it to propagate.
from contextlib import contextmanager
# 1. Class-based implementation
class DatabaseTransaction:
def __enter__(self):
print("BEGIN TRANSACTION")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print("ROLLBACK TRANSACTION due to:", exc_val)
return False # Re-raise exception
print("COMMIT TRANSACTION")
# 2. Generator-based implementation
@contextmanager
def managed_resource():
print("Acquiring resource")
try:
yield "Resource Handle"
finally:
print("Releasing resource (always runs)")8. What is the difference between `@classmethod`, `@staticmethod`, and instance methods?
| Method Type | First Argument | Primary Use Case |
|---|---|---|
| Instance Method | self (Instance) | Access or modify instance state and class state. |
@classmethod | cls (Class) | Factory methods, alternative constructors (e.g. from_json()), modify class state. |
@staticmethod | None (Standard arguments) | Self-contained utility functions logically grouped inside the class namespace. |
class User:
def __init__(self, username, email):
self.username = username
self.email = email
@classmethod
def from_dictionary(cls, data: dict):
"""Alternative constructor / factory pattern"""
return cls(data["username"], data["email"])
@staticmethod
def is_valid_email(email: str) -> bool:
"""Utility check not dependent on instance or class state"""
return "@" in email and "." in email9. Explain Method Resolution Order (MRO) and C3 Linearization.
When multiple inheritance is used in Python, the order in which base classes are searched for an attribute or method is called the Method Resolution Order (MRO).
Python utilizes the C3 Linearization algorithm (introduced in Python 2.3), which guarantees two properties:
- Subclasses precede parents: Children are searched before their base classes.
- Monotonicity: The order of parents specified in the class definition list is preserved.
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
# Viewing MRO:
print([cls.__name__ for cls in D.__mro__])
# Output: ['D', 'B', 'C', 'A', 'object']super() does not mean "call direct parent"; it means "call next class in the current instance's MRO chain". This is why cooperative multiple inheritance works cleanly in Python.10. When should you use `threading`, `multiprocessing`, or `asyncio`?
This is one of the most frequently asked architectural questions for senior developers:
asyncio(Single-thread cooperative multitasking): Best for thousands of concurrent I/O connections (e.g. WebSockets, high-throughput microservices, API scrapers). Minimal memory footprint because it avoids OS thread stack allocation.threading(Preemptive multithreading): Best for legacy I/O libraries that do not supportasync/awaitor background worker tasks with moderate concurrency (< 200 threads).multiprocessing(True multi-core parallelism): Essential for CPU-intensive computing (data transformations, video processing, model inference, cryptography) where each process gets its own separate Python interpreter and memory space, bypassing the GIL.
import asyncio
async def fetch_api(endpoint_id):
await asyncio.sleep(0.1) # Non-blocking I/O simulation
return "Data from " + str(endpoint_id)
async def main():
tasks = [fetch_api(i) for i in range(1000)]
results = await asyncio.gather(*tasks) # Concurrent execution
print("Fetched", len(results), "responses asynchronously!")
asyncio.run(main())11. What is the difference between `asyncio.gather()` and `asyncio.TaskGroup`?
Python 3.11 introduced Structured Concurrency through asyncio.TaskGroup as a superior, safer alternative to asyncio.gather().
- The Problem with
gather(): If one task raises an exception, other running tasks continue running in the background as orphaned "zombie" tasks unless explicitly canceled. - The Solution with
TaskGroup: If any task in aTaskGroupfails, all other tasks in the group are immediately and cleanly canceled, and any exceptions are bundled together in anExceptionGroup.
import asyncio
async def worker(n):
await asyncio.sleep(n)
return n * 2
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(worker(1))
task2 = tg.create_task(worker(2))
# Both tasks guaranteed finished or canceled here:
print("Results:", task1.result(), task2.result())
asyncio.run(main())12. What is the Default Mutable Argument trap and how do you prevent it?
In Python, default arguments are evaluated once at function definition time, not each time the function is called.
If you use a mutable object (like a list, dictionary, or set) as a default parameter, that single object is shared across all invocations that rely on the default.
# The Buggy Way:
def bad_append(val, target=[]):
target.append(val)
return target
print(bad_append(1)) # [1]
print(bad_append(2)) # [1, 2] ! (Shared state across invocations)
# The Idiomatic Pythonic Fix:
def safe_append(val, target=None):
if target is None:
target = []
target.append(val)
return target
print(safe_append(1)) # [1]
print(safe_append(2)) # [2] (Clean new list every time)None as the default value for mutable arguments and assign fresh instances inside the function body.13. Explain the Late Binding Closures trap in loops with lambdas.
Python closures look up variables when the inner function is executed, not when it is created. In a loop, all closures reference the same loop variable, which retains its final value after the loop concludes.
# The Bug:
funcs = [lambda x: x * i for i in range(3)]
print([f(2) for f in funcs]) # [4, 4, 4] instead of [0, 2, 4]! (i ended at 2)
# The Fix using default argument capture at definition time:
funcs_fixed = [lambda x, i=i: x * i for i in range(3)]
print([f(2) for f in funcs_fixed]) # [0, 2, 4] (Correct!)14. How are Python Dictionaries implemented internally? Why are they ordered?
Python dictionaries are implemented using Hash Tables with open addressing and pseudo-random perturbation for collision resolution.
- Insertion Order Guarantee (Python 3.7+): Python separates the hash indices array from the dense entries array. Keys and values are appended in insertion order to a dense array
entries = [(hash, key, value), ...], and a sparse arrayindicesmaps hash slots to indices in the entries array. This makes dicts ~20% to 25% more compact in memory and preserves insertion order deterministically. - Time Complexity: Average \(O(1)\) for lookup, insertion, and deletion; worst-case \(O(n)\) in rare scenarios with heavy hash collisions.
15. What are `__slots__` and when should you use them in production?
By default, Python objects store their instance attributes in a dynamic dictionary named __dict__. This provides flexibility (adding arbitrary attributes at runtime) but incurs high memory overhead (~150-300 bytes per instance).
By defining __slots__ = ('name', 'id'), you tell CPython to allocate a fixed-size array of pointers instead of a dynamic dictionary. This delivers 40% to 50% memory savings and faster attribute access when instantiating millions of objects.
class PointWithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class PointWithSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# PointWithSlots instances cannot have dynamic attributes (e.g. p.z = 10 raises AttributeError)
# but consume significantly less RAM when storing millions of data points!16. Explain Positional-Only (`/`) and Keyword-Only (`*`) parameters in Python functions.
Python 3.8 formalized function signature syntax with / and *:
- Arguments before
/are Positional-Only: callers cannot pass them as keyword arguments. This allows changing parameter names in future library versions without breaking client code. - Arguments after
*are Keyword-Only: callers must pass them explicitly by name, enforcing readability for boolean flags and configuration parameters.
def configure(ip, port, /, timeout=30, *, secure=True, retries=3):
# ip, port: MUST be positional
# timeout: can be positional or keyword
# secure, retries: MUST be keyword
pass
# Valid:
configure("127.0.0.1", 8080, 10, secure=True, retries=5)
# Invalid (TypeError):
# configure(ip="127.0.0.1", port=8080) # Error: positional-only passed as keyword