Back to Notes

Python Interview Questions

Core Language

Q: What is the difference between is and ==?

== checks value equality. is checks identity — whether both variables point to the same object in memory.

a = [1, 2, 3]
b = [1, 2, 3]
a == b   # True  — same value
a is b   # False — different objects

# Gotcha: small ints and interned strings are cached
x = 256; y = 256
x is y   # True (CPython caches ints -5 to 256)
x = 257; y = 257
x is y   # False

Q: What is the difference between mutable and immutable types?

Mutable — can be changed after creation: list, dict, set, bytearray Immutable — cannot be changed: int, float, str, tuple, frozenset, bytes

# Immutable — reassignment creates a new object
s = "hello"
s[0] = "H"  # TypeError

# Mutable — changes in place
lst = [1, 2, 3]
lst[0] = 99  # ✅

# Key implication: immutables are hashable → can be dict keys / set members
d = {(1, 2): "tuple key"}  # ✅
d = {[1, 2]: "list key"}   # TypeError — list is unhashable

Q: What is the mutable default argument gotcha?

Default arguments are evaluated once at function definition time, not on each call. A mutable default (like []) is shared across all calls.

# Bug
def append_to(item, lst=[]):
    lst.append(item)
    return lst

append_to(1)  # [1]
append_to(2)  # [1, 2]  ← not a fresh list!

# Fix
def append_to(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

Q: What is the difference between deepcopy and copy?

copy.copy()shallow copy: creates a new container but the nested objects are still references. copy.deepcopy()deep copy: recursively copies all nested objects.

import copy

original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)

original[0][0] = 99
shallow[0][0]  # 99 — shared inner list
deep[0][0]     # 1  — independent copy

Q: What are Python's scoping rules (LEGB)?

Python resolves names in this order: Local → Enclosing → Global → Built-in

x = "global"

def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)  # "local"
    inner()
    print(x)  # "enclosing"

outer()
print(x)  # "global"

Use global x to write to a global variable from inside a function. Use nonlocal x to write to an enclosing (not global) variable from a nested function.


Q: What is the GIL and why does it matter?

The Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time, even on multi-core CPUs.

  • I/O-bound tasksthreading works fine (GIL released during I/O waits)
  • CPU-bound tasks → use multiprocessing (separate processes, no GIL)
  • asyncio is cooperative concurrency — not truly parallel but efficient for I/O

Q: What is the difference between @staticmethod and @classmethod?

class MyClass:
    class_var = "shared"

    def instance_method(self):
        # has access to instance (self) and class
        return self.class_var

    @classmethod
    def class_method(cls):
        # receives class as first arg — can access/modify class state
        # often used as alternative constructors
        return cls()

    @staticmethod
    def static_method():
        # receives neither self nor cls
        # just a regular function namespaced inside the class
        return "no access to class or instance"

Q: What is __slots__ and when would you use it?

__slots__ restricts a class's attributes to a fixed set, preventing the creation of __dict__ per instance. Saves memory when creating many instances.

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

p = Point(1, 2)
p.z = 3  # AttributeError — z not in __slots__

OOP

Q: What are the four pillars of OOP in Python?

Encapsulation — bundle data + methods; hide internals with __private convention Abstraction — expose what, hide how (abc.ABC, @abstractmethod) Inheritance — child class inherits from parent (class Dog(Animal)) Polymorphism — same interface, different behaviour (method overriding, duck typing)


Q: What is duck typing?

"If it walks like a duck and quacks like a duck, it's a duck." Python doesn't check types — it checks whether an object has the required methods/attributes.

class Dog:
    def speak(self): return "Woof"

class Cat:
    def speak(self): return "Meow"

def make_sound(animal):
    print(animal.speak())  # works for any object with .speak()

make_sound(Dog())
make_sound(Cat())

Q: What is super() and when do you use it?

super() returns a proxy to the parent class, allowing you to call overridden methods without hardcoding the parent's name. Essential in multiple inheritance (follows MRO).

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)   # call Animal.__init__
        self.breed = breed

Q: What is MRO (Method Resolution Order)?

The order Python searches classes when looking up a method. Uses C3 linearisation. Check with ClassName.__mro__ or ClassName.mro().

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

D.__mro__  # (D, B, C, A, object)

Q: What are dunder (magic) methods? Give examples.

Special methods with double underscores that Python calls implicitly.

MethodCalled when
__init__Object created
__str__str(obj) or print(obj)
__repr__repr(obj), debugging
__len__len(obj)
__getitem__obj[key]
__setitem__obj[key] = val
__contains__x in obj
__eq__obj == other
__lt__obj < other
__add__obj + other
__enter__ / __exit__with obj:
__iter__ / __next__for x in obj:

Functions & Closures

Q: What is a closure?

A function that remembers variables from its enclosing scope even after the outer function has returned.

def make_multiplier(n):
    def multiplier(x):
        return x * n   # n is captured from enclosing scope
    return multiplier

double = make_multiplier(2)
double(5)   # 10 — n=2 is still alive

Q: What is a decorator and how does it work?

A decorator is a function that wraps another function to add behaviour before/after it runs. Uses closure + functools.wraps to preserve metadata.

from functools import wraps

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Done {func.__name__}")
        return result
    return wrapper

@log
def greet(name):
    print(f"Hello, {name}")

greet("Vatsal")
# Calling greet
# Hello, Vatsal
# Done greet

Without @wraps(func), greet.__name__ would be "wrapper" — breaking introspection tools.

Decorator with arguments requires a 3-level nested structure:

def repeat(n):           # level 1: receives config
    def decorator(func): # level 2: receives function
        @wraps(func)
        def wrapper(*args, **kwargs):  # level 3: runs on each call
            for _ in range(n):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def say_hi():
    print("Hi!")

Q: What is the difference between *args and **kwargs?

def func(*args, **kwargs):
    print(args)    # tuple of positional args
    print(kwargs)  # dict of keyword args

func(1, 2, name="Vatsal", age=25)
# (1, 2)
# {'name': 'Vatsal', 'age': 25}

# Spread operator — unpack when calling
nums = [1, 2, 3]
print(*nums)  # 1 2 3
d = {"sep": "-"}
print(1, 2, 3, **d)  # 1-2-3

Q: What is a lambda function?

Anonymous single-expression function. Useful for short callbacks.

square = lambda x: x ** 2
square(5)  # 25

# Common use with sorted, map, filter
nums = [3, 1, 4, 1, 5]
sorted(nums, key=lambda x: -x)  # [5, 4, 3, 1, 1]

Avoid for complex logic — use a named function for readability.


Generators & Iterators

Q: What is the difference between a generator and a list?

A list stores all values in memory at once. A generator yields values lazily — one at a time, only when asked. Uses O(1) memory regardless of size.

# List — all in memory
squares_list = [x**2 for x in range(1_000_000)]

# Generator — one at a time
squares_gen = (x**2 for x in range(1_000_000))

next(squares_gen)  # 0
next(squares_gen)  # 1

Q: What is yield and how does it work?

yield turns a function into a generator. Each call to next() runs until the next yield, then pauses — the local state is preserved between calls.

def countdown(n):
    while n > 0:
        yield n
        n -= 1

for x in countdown(3):
    print(x)   # 3, 2, 1

Q: What is a context manager and how do you create one?

An object that manages resources — guarantees setup (__enter__) and cleanup (__exit__) even if an exception occurs.

# Class-based
class ManagedFile:
    def __init__(self, path):
        self.path = path

    def __enter__(self):
        self.file = open(self.path)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        return False  # don't suppress exceptions

with ManagedFile("data.txt") as f:
    data = f.read()

# Generator-based (simpler)
from contextlib import contextmanager

@contextmanager
def managed_file(path):
    f = open(path)
    try:
        yield f
    finally:
        f.close()

Data Structures

Q: When would you use a tuple over a list?

Tuples when data is fixed and shouldn't change (coordinates, RGB colours, DB rows). Tuples are immutable → hashable → usable as dict keys or set elements. Also slightly faster to iterate than lists.


Q: What is the time complexity of common Python operations?

Operationlistdict / set
x in collectionO(n)O(1) average
appendO(1) amortised
insert(0, x)O(n)
pop() (end)O(1)
pop(0) (front)O(n)
get / set / deleteO(1) average
sorted()O(n log n)

Never use list.pop(0) for queues — use collections.deque instead (popleft() is O(1)).


Q: What are defaultdict, Counter, and OrderedDict?

from collections import defaultdict, Counter, OrderedDict

# defaultdict — no KeyError for missing keys
graph = defaultdict(list)
graph["a"].append("b")  # no need to check if "a" exists

# Counter — frequency map
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
c = Counter(words)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})
c.most_common(2)  # [('apple', 3), ('banana', 2)]

# OrderedDict — maintains insertion order (less needed since Python 3.7+)
od = OrderedDict()
od["first"] = 1
od["second"] = 2

Advanced Topics

Q: What is @property and why use it?

@property lets you define a method that is accessed like an attribute. Allows adding validation/logic later without breaking the API.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
c.radius        # 5  — looks like attribute access
c.area          # 78.5...
c.radius = -1   # ValueError

Q: What is dataclass and when would you use it?

@dataclass auto-generates __init__, __repr__, __eq__ (and optionally __hash__, __lt__, etc.) from class variable annotations. Less boilerplate than manual classes.

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    label: str = "origin"
    tags: list = field(default_factory=list)  # mutable default — use field()

p = Point(1.0, 2.0)
print(p)   # Point(x=1.0, y=2.0, label='origin', tags=[])

Q: What is the difference between __str__ and __repr__?

__repr__ — for developers: should be unambiguous, ideally eval-able back to the object. Shown in REPL, repr(), logging. __str__ — for end users: readable, human-friendly. Shown by print(), str().

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

    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"  # Point(1, 2)

    def __str__(self):
        return f"({self.x}, {self.y})"            # (1, 2)

If only __repr__ is defined, it's used as fallback for str().


Q: How does Python handle memory management?

Python uses reference counting + a cyclic garbage collector.

  • Each object has a reference count; when it hits 0, memory is freed immediately
  • The GC handles cyclic references (A → B → A) that ref counting alone can't break
  • del x decrements the ref count but doesn't guarantee immediate deallocation

Common Gotchas Cheatsheet

GotchaRule
Mutable default argUse None as default, initialise inside
is vs ==Use == for value comparison; is only for None checks
Late binding in closuresClosures capture variable, not value — use default=x to bind early
list * n shallow copy[[]] * 3 makes 3 refs to same list — use list comprehension
+= on tupleCreates new tuple; += on list mutates in place
Integer division7 / 2 = 3.5 (float); 7 // 2 = 3 (floor div)
except ExceptionCatches most errors but not SystemExit, KeyboardInterrupt — use except Exception not bare except
Chained comparison1 < x < 10 works in Python (evaluates as 1 < x and x < 10)
# Late binding gotcha
funcs = [lambda: i for i in range(3)]
[f() for f in funcs]   # [2, 2, 2] — all capture same i

# Fix — bind at definition time
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs]   # [0, 1, 2]

# Shallow list multiplication gotcha
matrix = [[0] * 3] * 3
matrix[0][0] = 1
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]  ← all rows are same object!

# Fix
matrix = [[0] * 3 for _ in range(3)]