Python Interview Questions and Answers
This page covers the Python questions asked most often in technical interviews, from core data types and syntax to OOP, decorators, generators, concurrency and output-prediction puzzles. Questions are grouped by level; try answering each one out loud before reading the answer, and run the code samples yourself to confirm the output.
Python interview questions: fresher level
What is Python and what are its key features?
Python is a high-level, general-purpose programming language known for its readable syntax and a huge ecosystem of libraries. It is used for web back ends, automation, scripting, data analysis and machine learning.
- Readable syntax: indentation defines code blocks, so programs look clean by default.
- Interpreted: you run code directly, without a separate build step.
- Dynamically typed: types are attached to objects and checked at runtime.
- Multi-paradigm: supports procedural, object-oriented and functional styles.
- Large standard library: plus a vast collection of third-party packages on PyPI.
- Automatic memory management and cross-platform support.
Interview tip: A common follow-up is a weakness of Python; mention that it is usually slower than compiled languages for CPU-heavy work.
Is Python an interpreted or a compiled language?
It is both, in a sense: CPython, the standard implementation, first compiles your source code into bytecode and then interprets that bytecode on the Python Virtual Machine.
The compile step happens automatically, so it feels like a purely interpreted language. For imported modules, CPython caches the bytecode as .pyc files inside a __pycache__ folder so later runs can skip recompiling unchanged files. Bytecode is not machine code: it is portable across operating systems but slower than native code. Other implementations exist too, such as PyPy, which adds a just-in-time (JIT) compiler for speed.
Interview tip: Simply saying "Python is interpreted" is the answer interviewers hope you will refine with the bytecode step.
What is dynamic typing in Python?
Dynamic typing means types are checked at runtime instead of being declared in advance, so the same variable name can refer to an integer now and a string later.
In Python a variable is just a name bound to an object, and the type belongs to the object, not the name. Python is also strongly typed: it does not silently convert between unrelated types, so "5" + 5 raises a TypeError. Type hints such as age: int are optional and are not enforced at runtime; external tools like mypy check them.
x = 10
print(type(x)) # <class 'int'>
x = "ten"
print(type(x)) # <class 'str'>
print("5" + str(5)) # 55Interview tip: Expect the follow-up "dynamic vs strong typing": Python is both dynamic and strong.
What is the difference between mutable and immutable types in Python?
A mutable object can be changed in place after it is created; an immutable object cannot, so any "change" actually produces a new object.
- Immutable:
int,float,bool,str,tuple,frozenset,bytes. - Mutable:
list,dict,set,bytearrayand most user-defined class instances.
This matters because two names can refer to the same mutable object, so a change through one name is visible through the other. Also, only hashable objects, which in practice means immutable ones, can be dictionary keys or set elements.
name = "ravi"
name.upper()
print(name) # ravi (strings never change in place)
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3] (a and b are the same list)
What is the difference between a list and a tuple?
A list is mutable and written with square brackets, while a tuple is immutable and written with parentheses, although it is really the commas that make a tuple.
- Lists have methods like
append,removeandsort; tuples do not. - A tuple is hashable when all its items are hashable, so it can be a dict key.
- Tuples use slightly less memory and signal fixed data, such as coordinates or a database row.
- A single-item tuple needs a trailing comma:
(5,).
point = (3, 4)
places = {point: "home"} # tuple as a dict key
print(type((5))) # <class 'int'>
print(type((5,))) # <class 'tuple'>
point[0] = 10 # TypeError: 'tuple' object does not support item assignmentInterview tip: Expect the follow-up: a tuple can contain a list, but such a tuple is no longer hashable.
What is the difference between a list, tuple, set and dictionary in Python?
These are the four main built-in collections, and the right choice depends on whether you need order, uniqueness, mutability or lookup by key.
- list
[1, 2, 2]: ordered, mutable, allows duplicates, indexed by position. - tuple
(1, 2, 2): ordered, immutable, allows duplicates. - set
{1, 2}: unordered, mutable, unique items only, fast membership tests. - dict
{'a': 1}: key-value pairs, mutable, unique hashable keys, keeps insertion order (guaranteed since Python 3.7).
Checking x in items is O(n) for a list but O(1) on average for a set or dict, which is why sets are used for fast lookups and removing duplicates. Note that {} creates an empty dict; use set() for an empty set.
Interview tip: Interviewers often ask how to remove duplicates while keeping order; list(dict.fromkeys(items)) does it.
What is a list comprehension?
A list comprehension is a compact expression that builds a new list by looping over an iterable and optionally filtering items, all in one line.
It is usually more readable and slightly faster than a for loop that calls append. The same syntax works for sets and dicts. Keep comprehensions short; once you need nested loops plus complex conditions, a regular loop is clearer.
nums = [1, 2, 3, 4, 5, 6]
squares = [n * n for n in nums if n % 2 == 0]
print(squares) # [4, 16, 36]
labels = ["even" if n % 2 == 0 else "odd" for n in nums[:3]]
print(labels) # ['odd', 'even', 'odd']
lengths = {w: len(w) for w in ["hi", "hello"]}
print(lengths) # {'hi': 2, 'hello': 5} Interview tip: Watch the position of if: a filter goes at the end, but an if-else expression goes before the for.
What is the difference between is and == in Python?
is and == in Python?== checks whether two objects have equal values, while is checks whether two names point to the very same object in memory.
== calls the __eq__ method, so each class decides what equality means. is compares identity, which is like checking id(a) == id(b). Use is only for singletons such as None, as in if x is None. Do not use is to compare numbers or strings; any match you see there is an implementation detail.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True
print(a is b) # False
print(a is c) # True
How does slicing work in Python?
Slicing extracts part of a sequence with seq[start:stop:step], where start is included, stop is excluded and every part is optional.
Negative indexes count from the end, so -1 is the last item. A negative step walks backwards, which is why [::-1] reverses a sequence. Unlike indexing, slicing never raises IndexError for out-of-range bounds; it just returns what exists. Slicing a list returns a new list, which is a shallow copy.
s = "PYTHON"
print(s[1:4]) # YTH
print(s[:2]) # PY
print(s[-3:]) # HON
print(s[::2]) # PTO
print(s[::-1]) # NOHTYP
print(s[2:100]) # THON
What are *args and **kwargs?
*args and **kwargs?*args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict, so a function can accept a variable number of arguments.
The names are only a convention; the single and double asterisks do the work. The parameter order is: regular parameters, *args, keyword-only parameters, then **kwargs. The same operators also unpack values when calling a function, as in f(*my_list, **my_dict).
def order(item, *args, **kwargs):
print(item, args, kwargs)
order("pizza", "large", "extra cheese", city="Pune", paid=True)
# pizza ('large', 'extra cheese') {'city': 'Pune', 'paid': True}Interview tip: Decorators are the classic real-world use, because a wrapper must pass through whatever arguments it receives.
What is a lambda function in Python?
A lambda is a small anonymous function written with the lambda keyword; it can take any number of arguments but holds only a single expression, whose value is returned automatically.
Lambdas are mostly used as short throwaway functions passed to other functions, such as the key for sorted() or the function for map() and filter(). They cannot contain statements like loops, return or regular assignments. If you find yourself giving a lambda a name, PEP 8 recommends writing a normal def instead.
print((lambda x, y: x * y)(4, 5)) # 20
students = [("Asha", 82), ("Rohan", 91), ("Meera", 76)]
print(sorted(students, key=lambda s: s[1]))
# [('Meera', 76), ('Asha', 82), ('Rohan', 91)]
How does exception handling work with try, except, else and finally?
Code that might fail goes in try, except handles specific errors, else runs only when no exception occurred, and finally always runs, whether an error happened or not.
Catch specific exceptions rather than using a bare except:, which also swallows things like KeyboardInterrupt. Keep only the risky line in try and put the follow-up logic in else. Use finally for cleanup such as closing connections. Raise your own errors with raise ValueError("message").
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("cannot divide by zero")
else:
print("result:", result)
finally:
print("done")
divide(10, 2) # result: 5.0, then done
divide(1, 0) # cannot divide by zero, then done Interview tip: A favourite trick: if both try and finally contain return, the value from finally wins, which is why newer Python versions warn about return inside finally.
What are __init__ and self in a Python class?
__init__ and self in a Python class?__init__ is the initializer method that runs automatically right after a new object is created, and self is the reference to that particular object, passed as the first argument to every instance method.
__init__ is often called the constructor, but strictly the object is created by __new__ and then set up by __init__. self is not a keyword, just a very strong convention. When you call acc.deposit(500), Python effectively runs Account.deposit(acc, 500), which is how the method knows which object to work on.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
acc = Account("Priya")
acc.deposit(500)
print(acc.owner, acc.balance) # Priya 500
What does if __name__ == "__main__": do?
if __name__ == "__main__": do?It runs a block of code only when the file is executed directly, not when it is imported as a module by another file.
Every module has a built-in __name__ variable. When you run python utils.py, Python sets __name__ to "__main__" for that file. When another file does import utils, __name__ is "utils" instead, so the guarded block is skipped. This keeps demo code, tests or script entry points from running on import.
Related terms: a module is a single .py file, while a package is a directory of modules, traditionally marked with an __init__.py file.
# utils.py
def greet(name):
return f"Hello, {name}"
if __name__ == "__main__":
print(greet("Arjun")) # runs only with: python utils.py
What are f-strings and how do they compare with other string formatting methods?
f-strings are string literals prefixed with f that evaluate expressions inside curly braces at runtime, and they are the preferred way to format strings in modern Python.
The older options are % formatting and str.format(). f-strings are shorter, easier to read and generally faster. They support format specifiers after a colon, for decimals, padding, percentages and thousands separators, and the = specifier prints both the expression and its value, which is handy for debugging.
name, marks, total = "Kiran", 456, 500
print(f"{name} scored {marks / total:.2%}") # Kiran scored 91.20%
print(f"[{marks:>6}]") # [ 456]
print(f"{1234567:,}") # 1,234,567
print(f"{marks=}") # marks=456
print("{} scored {}".format(name, marks)) # Kiran scored 456Python interview questions: intermediate level
What is the difference between a shallow copy and a deep copy?
A shallow copy creates a new outer object but keeps references to the same inner objects, while a deep copy recursively copies everything, so the result is fully independent.
Shallow copies come from copy.copy(), list.copy(), dict.copy() or slicing with [:]. They are fine for flat lists of immutable items. With nested mutable data, a change to an inner object shows up in both copies, so use copy.deepcopy(). Plain assignment b = a makes no copy at all.
import copy
a = [[1, 2], [3, 4]]
shallow = copy.copy(a)
deep = copy.deepcopy(a)
a[0].append(99)
print(shallow) # [[1, 2, 99], [3, 4]]
print(deep) # [[1, 2], [3, 4]]Interview tip: This often comes as an output question, so trace exactly which inner lists are shared.
Why is a mutable default argument a bad idea in Python?
Default argument values are evaluated only once, when the function is defined, so a mutable default such as a list is shared by every call that does not pass that argument.
Changes made during one call therefore leak into the next call. The standard fix is to use None as the default and create a fresh object inside the function body.
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b']
def add_item_fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_item_fixed("b")) # ['b']Interview tip: Say the key phrase explicitly: defaults are evaluated once, at definition time, not on every call.
What is a generator and how does yield work?
yield work?A generator is a function that uses yield to produce values one at a time, pausing after each value and resuming from the same point when the next value is requested.
Calling a generator function does not run its body; it returns a generator object. Each next() runs the code up to the next yield. When the function finishes, StopIteration is raised, which for loops handle for you. Because values are produced lazily, generators use very little memory, which suits large files, streams and infinite sequences. A generator can be consumed only once.
def countdown(n):
while n > 0:
yield n
n -= 1
gen = countdown(3)
print(next(gen)) # 3
print(list(gen)) # [2, 1]
print(list(gen)) # [] Interview tip: A common follow-up: return ends a function for good, while yield pauses it and keeps its local state.
What is the difference between an iterator and an iterable?
An iterable is any object you can loop over because it can hand out an iterator through __iter__, while an iterator is the object that actually produces values one by one through __next__ and remembers its position.
Lists, tuples, strings and dicts are iterables; calling iter() on them returns a fresh iterator. A for loop does exactly that behind the scenes and then calls next() until StopIteration. Every iterator is also iterable, because its __iter__ returns itself, but a list is not an iterator. Iterators are single-use, while most iterables can be looped over again.
nums = [10, 20]
it = iter(nums)
print(next(it)) # 10
print(next(it)) # 20
print(next(it, "done")) # done (default instead of StopIteration)
# next(nums) raises TypeError: 'list' object is not an iterator
What is a decorator in Python?
A decorator is a function that takes another function, wraps it with extra behaviour and returns the wrapped version, without changing the original function's code.
Writing @log_call above a function is shorthand for add = log_call(add). Decorators are used for logging, timing, caching, authentication checks and route registration in web frameworks. Apply functools.wraps to the inner wrapper so the original function keeps its name and docstring. Decorators work because Python functions are first-class objects and inner functions can close over outer variables.
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def add(a, b):
return a + b
print(add(2, 3)) # calling add, then 5Interview tip: Be ready to write one from scratch, and possibly a decorator that takes its own arguments, which needs one more level of nesting.
What do map, filter and reduce do in Python?
map, filter and reduce do in Python?map applies a function to every item, filter keeps only the items for which a function returns true, and reduce combines all items into a single value by applying a function cumulatively.
In Python 3, map and filter return lazy iterators, so wrap them in list() to see the values. reduce is no longer a built-in; import it from functools. Many developers prefer comprehensions over map and filter for readability, and built-ins such as sum and max over reduce.
from functools import reduce
nums = [1, 2, 3, 4]
print(list(map(lambda x: x * 10, nums))) # [10, 20, 30, 40]
print(list(filter(lambda x: x % 2 == 0, nums))) # [2, 4]
print(reduce(lambda acc, x: acc * x, nums)) # 24
What is the LEGB rule in Python?
LEGB is the order in which Python looks up a name: Local, then Enclosing function scopes, then Global (module level), and finally Built-in.
The first match wins, which is how you can accidentally shadow a built-in by naming a variable list or sum. Whether a name is local is decided when the function is compiled: if a function assigns to a name anywhere in its body, that name is local throughout the function. Reading it before the assignment then raises UnboundLocalError.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x)
inner()
print(x)
outer() # local, then enclosing
print(x) # global Interview tip: A favourite follow-up is why count += 1 inside a function fails when count is a global.
What do the global and nonlocal keywords do?
global and nonlocal keywords do?global lets a function rebind a module-level variable, and nonlocal lets a nested function rebind a variable from its nearest enclosing function.
Without these keywords, assigning to a name inside a function creates a new local variable. Simply reading an outer variable never needs them, and neither does mutating an outer list with append, because the name itself is not being rebound. Heavy use of global is usually a design smell; returning values is cleaner.
count = 0
def increment():
global count
count += 1
def make_counter():
total = 0
def step():
nonlocal total
total += 1
return total
return step
increment()
c = make_counter()
print(count, c(), c()) # 1 1 2
What is the difference between @staticmethod and @classmethod?
@staticmethod and @classmethod?A class method receives the class itself as its first argument, conventionally named cls, while a static method receives no automatic first argument and behaves like a plain function placed inside the class.
Use @classmethod for alternative constructors and for logic that should respect subclasses, because cls will be the subclass when called on one. Use @staticmethod for helper logic that belongs to the class conceptually but needs neither the instance nor the class. Regular methods receive the instance as self.
class Date:
def __init__(self, d, m, y):
self.d, self.m, self.y = d, m, y
@classmethod
def from_string(cls, text):
d, m, y = map(int, text.split("-"))
return cls(d, m, y)
@staticmethod
def is_leap(y):
return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
print(Date.from_string("15-08-2026").y) # 2026
print(Date.is_leap(2024)) # True Interview tip: Interviewers usually want one concrete use case for each, and an alternative constructor is the strongest example for @classmethod.
What is @property used for?
@property used for?@property turns a method into an attribute-style accessor, so you can add validation or computed values while callers keep using simple obj.attr syntax.
You define the getter with @property and an optional setter with @name.setter. This is the Pythonic replacement for Java-style getter and setter methods: start with a plain public attribute and switch to a property later without breaking calling code. Store the real value under a different name, usually with a leading underscore, otherwise the setter would call itself forever.
class Employee:
def __init__(self, salary):
self.salary = salary # goes through the setter
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("salary cannot be negative")
self._salary = value
e = Employee(50000)
print(e.salary) # 50000
How does inheritance work in Python, and what does super() do?
super() do?Inheritance lets a child class reuse and extend a parent class, and super() returns a proxy object that calls the next class in the method resolution order, which is usually the parent.
Python supports single, multilevel, hierarchical and multiple inheritance. A child can override any method, and calling super().__init__(...) makes sure the parent's setup still runs. Prefer super() over hardcoding the parent class name, because in multiple inheritance it follows the MRO so each class in the chain runs once.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
def speak(self):
return f"{self.name} says woof"
d = Dog("Tommy", "Indie")
print(d.speak(), isinstance(d, Animal)) # Tommy says woof True
What are dunder (magic) methods, and what is the difference between __str__ and __repr__?
__str__ and __repr__?Dunder methods are special methods named with double underscores, such as __init__, __len__ and __add__, that Python calls automatically to make your objects work with built-in operations and functions.
__str__ returns a readable string for end users and is used by print() and str(). __repr__ returns an unambiguous developer-facing string, ideally one that looks like code to recreate the object; it is used in the interactive shell, inside containers, and as the fallback when __str__ is missing. Other examples are __eq__ for ==, __lt__ for <, __getitem__ for indexing and __iter__ for loops.
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
return f"Money({self.amount})"
def __str__(self):
return f"Rs. {self.amount}"
def __add__(self, other):
return Money(self.amount + other.amount)
total = Money(100) + Money(50)
print(total) # Rs. 150
print([total]) # [Money(150)] Interview tip: If you define only one of the two, define __repr__, since str() falls back to it.
What is a context manager and how does the with statement work?
with statement work?A context manager is an object that sets something up on entry and reliably cleans it up on exit, and the with statement guarantees that cleanup runs even if an exception occurs inside the block.
Under the hood, with calls the object's __enter__ method, binds its return value to the name after as, and calls __exit__ when the block ends. Files, locks and database connections all work this way. You can write your own with a class that defines both methods or, more simply, with contextlib.contextmanager and a generator.
import contextlib, time
@contextlib.contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally:
print(f"{label} took {time.perf_counter() - start:.3f}s")
with open("notes.txt", "w") as f: # file is closed automatically
f.write("hello")
with timer("loop"):
sum(range(1_000_000))
What is the difference between a list comprehension and a generator expression?
A list comprehension uses square brackets and builds the entire list in memory immediately, while a generator expression uses parentheses and produces items lazily, one at a time, as they are requested.
Use a generator expression when you only need to iterate once, especially over large data, or when passing it straight into functions like sum, any or max. Use a list when you need indexing, len() or multiple passes. Like any generator, a generator expression is exhausted after one pass.
import sys
squares_list = [n * n for n in range(1_000_000)]
squares_gen = (n * n for n in range(1_000_000))
print(sys.getsizeof(squares_list) > sys.getsizeof(squares_gen)) # True
print(sum(n * n for n in range(5))) # 30
print(sum(squares_gen) == sum(squares_gen)) # False: second pass sums nothing
What is the difference between sort() and sorted(), and how does the key parameter work?
sort() and sorted(), and how does the key parameter work?list.sort() sorts a list in place and returns None, while sorted() accepts any iterable and returns a new sorted list, leaving the original unchanged.
Both accept key, a function called once per item whose result is used for comparison, and reverse=True for descending order. Python's sort is stable, so items with equal keys keep their original relative order. Returning a tuple from key sorts by several fields at once.
words = ["banana", "apple", "Fig", "cherry"]
print(sorted(words)) # ['Fig', 'apple', 'banana', 'cherry']
print(sorted(words, key=str.lower)) # ['apple', 'banana', 'cherry', 'Fig']
print(sorted(words, key=len)) # ['Fig', 'apple', 'banana', 'cherry']
print(sorted(words, key=lambda w: (-len(w), w)))
# ['banana', 'cherry', 'apple', 'Fig']
result = words.sort()
print(result) # None Interview tip: A classic bug they may show you: nums = nums.sort() leaves nums set to None.
What is a virtual environment, and why do you use one with pip?
A virtual environment is an isolated folder with its own Python executable and its own installed packages, so each project can use its own dependency versions without clashing with other projects or the system Python.
Create one with the built-in venv module, activate it, and then use pip to install packages into it. Record the dependencies in a requirements.txt file so teammates and servers can recreate the same environment. Do not commit the environment folder itself to version control.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install requests
pip freeze > requirements.txt
pip install -r requirements.txt # on another machine
deactivateInterview tip: Give the concrete reason: two projects that need different versions of the same library cannot share one global install.
Python interview questions: advanced level
What is the GIL (Global Interpreter Lock) in Python?
The GIL is a lock in CPython that allows only one thread to execute Python bytecode at a time within a process, even on a multi-core machine.
It exists mainly because CPython's reference-counting memory management is not thread-safe on its own, and one global lock kept the interpreter simple and fast for single-threaded code. The consequence is that threads do not speed up CPU-bound pure Python code. They still help with I/O-bound work, because the GIL is released while a thread waits on the network or disk, and many C extensions release it during heavy computation. Recent CPython versions also offer an optional free-threaded build without the GIL, but the standard build still has it.
Interview tip: Say "CPython" rather than "Python": the GIL belongs to the implementation, not the language itself.
When would you use threading, multiprocessing or asyncio in Python?
Use multiprocessing for CPU-bound work, and threading or asyncio for I/O-bound work such as network calls, file access or database queries.
- threading: several threads in one process sharing memory. Simple for a few blocking I/O tasks, but shared data needs locks, and the GIL prevents parallel CPU work.
- multiprocessing: separate processes, each with its own interpreter and GIL, so they truly run in parallel on multiple cores. Data passed between them must be pickled, which adds overhead.
- asyncio: one thread runs an event loop that switches between coroutines at each
await. It handles thousands of concurrent connections cheaply, but the libraries you call must be async-aware.
import asyncio
async def fetch(i):
await asyncio.sleep(1) # stands in for a network call
return i
async def main():
results = await asyncio.gather(*(fetch(i) for i in range(3)))
print(results) # [0, 1, 2] after about 1 second, not 3
asyncio.run(main()) Interview tip: concurrent.futures offers ThreadPoolExecutor and ProcessPoolExecutor with the same interface, which is a good practical answer.
How does Python manage memory and garbage collection?
CPython manages memory automatically, using reference counting as the main mechanism plus a cyclic garbage collector for groups of objects that refer to each other.
Every object keeps a count of references pointing to it. When that count drops to zero, for example when a variable goes out of scope or is removed with del, the object is freed immediately. Reference counting alone cannot free cycles, such as two objects referencing each other, so the gc module periodically finds unreachable cycles and collects them. Objects live on a private heap managed by Python's own allocator, and you can force a collection with gc.collect().
import gc
class Node:
pass
a, b = Node(), Node()
a.other, b.other = b, a # a reference cycle
del a, b # refcounts never reach zero on their own
gc.collect() # the cycle collector finds and frees themInterview tip: Memory leaks in Python usually come from references you keep by accident, such as an ever-growing global cache, rather than from the collector failing.
What is the MRO, and how does Python resolve the diamond problem?
The Method Resolution Order (MRO) is the order in which Python searches classes for a method or attribute, computed with the C3 linearization algorithm so that each class appears once and children always come before their parents.
In a diamond, where D inherits from B and C, and both inherit from A, the MRO is D, B, C, A, object. You can inspect it with D.__mro__ or D.mro(). Because super() follows the MRO of the actual object rather than the direct parent, cooperative super() calls visit every class exactly once.
class A:
def hi(self): return "A"
class B(A):
def hi(self): return "B" + super().hi()
class C(A):
def hi(self): return "C" + super().hi()
class D(B, C):
def hi(self): return "D" + super().hi()
print(D().hi()) # DBCA
print([k.__name__ for k in D.__mro__]) # ['D', 'B', 'C', 'A', 'object'] Interview tip: The insight they are looking for: super() inside B calls C here, not A.
Why does a is b return True for some equal integers but False for others?
a is b return True for some equal integers but False for others?Because CPython reuses some immutable objects as an optimisation, so equal values may or may not end up being the same object; this is an implementation detail, not a language guarantee.
CPython pre-creates the small integers from -5 to 256, so every use of those values refers to one shared object. Larger integers created at runtime are normally separate objects. The compiler may also share identical constants within the same block of code, and many short strings are interned, so results can differ between a script and the interactive shell. The lesson: compare values with == and keep is for None.
a = 256
b = 256
print(a is b) # True (cached small int in CPython)
x = int("1000")
y = int("1000")
print(x is y) # False (two separate objects in CPython)
print(x == y) # True Interview tip: Avoid claiming the result for literals like a = 1000; b = 1000 is always False; compile-time constant sharing can make it True.
Why do lambdas created in a loop all return the same value?
Because closures capture variables, not values: each lambda looks up i only when it is called, and by then the loop has finished with i holding its last value.
This is called late binding. All three functions share the same variable, so they all return 2. The usual fix is to bind the current value as a default argument, because defaults are evaluated when each function is defined. functools.partial works as well.
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]Interview tip: Connect it to the mutable default argument question: both come down to when an expression is evaluated.
What does [[0] * 3] * 3 create, and why is it a trap?
[[0] * 3] * 3 create, and why is it a trap?It creates an outer list holding three references to the same inner list, so changing one row appears to change every row.
The * operator on a list repeats references, not copies. For the inner [0] * 3 that is harmless because integers are immutable, but the outer * 3 repeats one mutable list three times. Build a grid with a comprehension instead, which creates a fresh inner list on every iteration.
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
What happens when you run t[0] += [3] on a tuple that holds a list?
t[0] += [3] on a tuple that holds a list?It raises a TypeError, yet the list inside the tuple is still modified, which surprises most people.
The statement runs in two steps. First Python calls the list's in-place __iadd__, which extends the list successfully. Then it tries to store the result back with t[0] = ..., and that assignment fails because tuples do not support item assignment. The mutation has already happened, so you get both the change and the error. Use t[0].append(3) or t[0].extend([3]), which never attempt the assignment.
t = ([1, 2], "x")
try:
t[0] += [3]
except TypeError as e:
print(e) # 'tuple' object does not support item assignment
print(t) # ([1, 2, 3], 'x')
What is the difference between class attributes and instance attributes?
A class attribute is defined on the class and shared by all its instances, while an instance attribute is set on self and belongs to one object only.
Attribute lookup checks the instance first, then the class. Assigning through an instance, such as s1.school = "XYZ", creates a new instance attribute that hides the class one for that object only. The real trap is a mutable class attribute such as a list: appending through any instance changes the one shared list for everyone. Define per-object data in __init__.
class Student:
school = "ABC"
subjects = [] # shared by all instances
s1, s2 = Student(), Student()
s1.school = "XYZ" # new instance attribute on s1 only
s1.subjects.append("Maths") # mutates the shared list
print(s1.school, s2.school) # XYZ ABC
print(s2.subjects) # ['Maths']
print(Student.school) # ABC Interview tip: A related output question: inside __init__, self.count += 1 creates an instance attribute, while Student.count += 1 updates the shared counter.
What is the output of print({1: 'a', 1.0: 'b', True: 'c'})?
print({1: 'a', 1.0: 'b', True: 'c'})?It prints {1: 'c'}, because 1, 1.0 and True are equal and have the same hash, so the dictionary treats them as one key.
Dict keys are matched by hash first and then by equality. True == 1 == 1.0 is true, and their hashes are equal too. When a duplicate key appears, the dictionary keeps the original key object but overwrites the value, so the first key 1 survives with the last value 'c'. This is also why keys must be hashable: a list cannot be a key, but a tuple of immutable items can.
d = {1: "a", 1.0: "b", True: "c"}
print(d) # {1: 'c'}
print(len({1, 1.0, True})) # 1
print(hash(1) == hash(1.0) == hash(True)) # True Interview tip: Since bool is a subclass of int, True + True gives 2, and interviewers often chain these questions together.