Technical Masterclass

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.

Core

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_refcnt in 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 with del, 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.
Python 3 Code
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 GC
Interview Tip: Mention sys.getrefcount(), the fact that reference count cleanup is instantaneous and deterministic, and why the gc module is only needed for reference cycles.
Core

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 multiprocessing module 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.
Python 3 Code
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")
Interview Tip: High-scoring candidates differentiate between I/O-bound (where threading/asyncio works great) and CPU-bound tasks (where multiprocessing is needed), and cite PEP 703 for modern bonus points.
Core

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.

Python 3 Code
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)
Interview Tip: Never use is for comparison of values like numbers or strings, except when checking singleton objects like var is None or var is True.
Core

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.
Python 3 Code
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]]
Interview Tip: Deep copy is significantly slower and memory-intensive because of recursive cloning and memo table lookups. Only use it when complete independence of nested structures is required.
Advanced

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).

Python 3 Code
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)
Interview Tip: Explain the 3-level nesting required for decorators with arguments: outer function receives configuration arguments, middle function receives the target function, inner function receives the actual runtime arguments.
Advanced

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.
Python 3 Code
# 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")
Advanced

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 after as.
  • __exit__(self, exc_type, exc_val, exc_tb): Guarantees teardown. If an exception occurred inside the with block, its type, value, and traceback are passed. Returning True suppresses the exception; returning False (or None) allows it to propagate.
Python 3 Code
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)")
OOP

8. What is the difference between `@classmethod`, `@staticmethod`, and instance methods?

Method TypeFirst ArgumentPrimary Use Case
Instance Methodself (Instance)Access or modify instance state and class state.
@classmethodcls (Class)Factory methods, alternative constructors (e.g. from_json()), modify class state.
@staticmethodNone (Standard arguments)Self-contained utility functions logically grouped inside the class namespace.
Python 3 Code
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 email
OOP

9. 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:

  1. Subclasses precede parents: Children are searched before their base classes.
  2. Monotonicity: The order of parents specified in the class definition list is preserved.
Python 3 Code
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']
Interview Tip: Calling 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.
Async

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 support async/await or 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.
Python 3 Code
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())
Async

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 a TaskGroup fails, all other tasks in the group are immediately and cleanly canceled, and any exceptions are bundled together in an ExceptionGroup.
Python 3.11+ Code
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())
Tricky

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.

Python 3 Code
# 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)
Interview Tip: Always use None as the default value for mutable arguments and assign fresh instances inside the function body.
Tricky

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.

Python 3 Code
# 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!)
Core

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 array indices maps 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.
OOP

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.

Python 3 Code
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!
Core

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.
Python 3 Code
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