Top 50 Python Interview Questions for Mid-Level Developers
Preparing for a mid-level Python developer interview requires a solid grasp of both fundamental and advanced concepts. This tutorial walks you through 50 essential questions, grouped by topic, with practical code examples, explanations, and best practices. Whether you're brushing up on core mechanics or diving into decorators and concurrency, this guide will help you articulate your knowledge confidently.
Why This Matters
Mid-level developers are expected to go beyond syntax. Interviewers want to see understanding of Python's internals, idiomatic patterns, performance trade-offs, and the ability to write maintainable code. Mastering these questions demonstrates that you can both build features and reason about how Python executes them.
Part 1: Core Python Concepts (Questions 1-10)
1. What is the difference between is and ==?
== compares values for equality, while is compares object identity (memory address). Use is mainly for None, True, and False comparisons.
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True - same values
print(a is b) # False - different objects
c = None
print(c is None) # True - correct idiom
2. Explain mutable vs immutable types
Mutable objects (lists, dicts, sets) can be changed after creation. Immutable objects (tuples, strings, ints, frozensets) cannot. Reassigning an immutable creates a new object.
# Mutable - same object modified
lst = [1, 2]
print(id(lst)) # 140...
lst.append(3)
print(id(lst)) # same id
# Immutable - new object created
s = "hello"
print(id(s))
s += " world"
print(id(s)) # different id
3. What is the GIL and why does it matter?
The Global Interpreter Lock (GIL) ensures only one thread executes Python bytecode at a time in CPython. This means threading doesn't speed up CPU-bound tasks but works fine for I/O-bound work. Use multiprocessing for CPU-bound parallelism.
4. How does Python handle memory management?
Python uses reference counting plus a cyclic garbage collector. When an object's reference count hits zero, it's deallocated. The GC handles reference cycles. Use sys.getrefcount() and gc module to inspect.
import sys, gc
a = [1, 2, 3]
print(sys.getrefcount(a) - 1) # 1 (subtract the ref from getrefcount itself)
gc.collect() # manually trigger cycle collection
5. What is the difference between deepcopy and copy?
copy.copy() creates a shallow copy (new container, same element references). copy.deepcopy() recursively copies all nested objects.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
shallow[0][0] = 99
print(original) # [[99, 2], [3, 4]] - nested list shared
deep[0][0] = 99
print(original) # unchanged after deep copy
6. What are dunder methods?
Dunder (double underscore) methods like __init__, __str__, __repr__, __eq__ let you define object behavior for built-in operations.
class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other):
return Money(self.amount + other.amount)
def __repr__(self):
return f"Money({self.amount})"
print(Money(10) + Money(20)) # Money(30)
7. Explain the difference between __new__ and __init__
__new__ creates and returns a new instance (it's a classmethod-like static method). __init__ initializes the already-created instance. __new__ is used for immutables, singletons, and metaclasses.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
a = Singleton()
b = Singleton()
print(a is b) # True
8. What is the difference between @staticmethod and @classmethod?
@staticmethod receives no implicit first argument. @classmethod receives the class as the first argument. Use classmethods for alternative constructors.
class Date:
def __init__(self, year, month, day):
self.year, self.month, self.day = year, month, day
@classmethod
def from_string(cls, date_str):
y, m, d = map(int, date_str.split('-'))
return cls(y, m, d)
@staticmethod
def is_valid(date_str):
return len(date_str.split('-')) == 3
d = Date.from_string("2024-01-15")
print(Date.is_valid("2024-01-15")) # True
9. What is the difference between range and xrange?
In Python 3, range is a lazy sequence (like Python 2's xrange). It doesn't generate all values in memory. Python 2's range returned a full list. In modern Python, always use range.
10. How does Python's import system work?
Python searches sys.path for modules, caches them in sys.modules, and executes the module on first import. Packages use __init__.py. Use absolute imports over relative ones for clarity.
import sys
print(sys.path[:3]) # search paths
print('os' in sys.modules) # True after import os
Part 2: Data Structures (Questions 11-20)
11. When would you use a set vs a list?
Use a set for membership testing and uniqueness (O(1) lookup). Use a list when order matters, duplicates are allowed, or you need indexing.
# Fast deduplication
items = [1, 2, 2, 3, 3, 3]
unique = list(set(items)) # [1, 2, 3]
# Fast membership
valid_ids = {101, 102, 103}
print(101 in valid_ids) # O(1) vs O(n) for list
12. What is a defaultdict and when do you use it?
collections.defaultdict auto-creates missing keys with a default factory, eliminating KeyError checks.
from collections import defaultdict
word_counts = defaultdict(int)
for word in "the cat sat on the mat".split():
word_counts[word] += 1
print(dict(word_counts)) # {'the': 2, 'cat': 1, ...}
13. Explain Counter and its common methods
Counter is a dict subclass for counting hashable objects. Useful methods include most_common(), elements(), and arithmetic operations.
from collections import Counter
c = Counter("abracadabra")
print(c.most_common(2)) # [('a', 5), ('b', 2)]
print(c + Counter("aaa")) # Counter({'a': 8, ...})
14. What is the difference between sort() and sorted()?
list.sort() sorts in place and returns None. sorted() returns a new sorted list and works on any iterable.
nums = [3, 1, 4, 1, 5]
nums.sort() # in-place
print(nums) # [1, 1, 3, 4, 5]
words = ["banana", "apple", "cherry"]
print(sorted(words, key=len)) # ['apple', 'banana', 'cherry']
15. How do you sort a list of dictionaries by a key?
people = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
]
sorted_people = sorted(people, key=lambda p: p["age"])
print(sorted_people) # Bob first
16. What is a deque and when should you use it?
collections.deque is a double-ended queue with O(1) append/pop from both ends, unlike lists which are O(n) at the front.
from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0)
dq.append(4)
dq.popleft() # 0 - O(1)
print(dq) # deque([1, 2, 3, 4])
17. How do you merge two dictionaries?
a = {"x": 1, "y": 2}
b = {"y": 3, "z": 4}
# Python 3.9+
merged = a | b # {'x': 1, 'y': 3, 'z': 4}
# Python 3.5+
merged = {**a, **b}
# In-place update
a.update(b)
18. What is a namedtuple and why use it?
namedtuple creates tuple subclasses with named fields, combining tuple immutability with attribute access for readability.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p._asdict()) # {'x': 3, 'y': 4}
19. How do you flatten a nested list?
nested = [[1, 2], [3, 4], [5]]
# Simple
flat = [item for sublist in nested for item in sublist]
# Recursive for arbitrary depth
def flatten(lst):
result = []
for item in lst:
if isinstance(item, list):
result.extend(flatten(item))
else:
result.append(item)
return result
print(flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]
20. What is the time complexity of common dict operations?
Average case: get/set/delete/in are O(1). Iteration is O(n). Worst case (hash collisions) degrades to O(n). Python 3.7+ guarantees insertion order.
Part 3: Functions & Decorators (Questions 21-30)
21. What are *args and **kwargs?
*args collects positional arguments into a tuple. **kwargs collects keyword arguments into a dict. Order must be: positional, *args, defaults, **kwargs.
def func(a, b, *args, **kwargs):
print(a, b) # 1 2
print(args) # (3, 4)
print(kwargs) # {'x': 10}
func(1, 2, 3, 4, x=10)
22. What is a closure?
A closure is a function that remembers variables from its enclosing scope even after that scope has finished executing.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter(), counter()) # 1 2
23. How do decorators work?
A decorator is a function that takes another function and extends its behavior without modifying it. Use functools.wraps to preserve metadata.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@log_calls
def greet(name):
"""Greet someone."""
return f"Hello, {name}"
print(greet("Alice"))
24. How do you write a decorator with arguments?
You need three levels of nesting: the argument receiver, the decorator, and the wrapper.
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hi():
print("Hi!")
say_hi() # prints Hi! three times
25. What is the difference between a generator and a list?
Generators produce values lazily using yield, saving memory. Lists store all values in memory upfront.
# Generator - memory efficient
def count_up_to(n):
i = 0
while i < n:
yield i
i += 1
gen = count_up_to(1000000) # minimal memory
print(next(gen)) # 0
print(next(gen)) # 1
# Generator expression
squares = (x**2 for x in range(10))
print(list(squares))
26. What is yield and how does it differ from return?
return sends a value and terminates the function. yield sends a value but pauses the function's state, allowing resumption. Functions with yield return generator objects.
27. What are lambda functions and their limitations?
Lambdas are anonymous, single-expression functions. They can't contain statements, multiple expressions, or docstrings. Use them for short, simple operations.
# Good use - short key function
sorted(items, key=lambda x: x.priority)
# Avoid - complex logic belongs in def
# bad = lambda x: complex_operation(x)
28. What is map, filter, and reduce?
from functools import reduce
nums = [1, 2, 3, 4, 5]
# map - transform each element
squared = list(map(lambda x: x**2, nums)) # [1, 4, 9, 16, 25]
# filter - select elements
evens = list(filter(lambda x: x % 2 == 0, nums)) # [2, 4]
# reduce - accumulate to single value
total = reduce(lambda a, b: a + b, nums) # 15
# Idiomatic alternatives (often preferred)
squared = [x**2 for x in nums]
evens = [x for x in nums if x % 2 == 0]
total = sum(nums)
29. What is partial application?
functools.partial creates a new function with some arguments pre-filled, useful for configuration.
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27
30. How does functools.lru_cache work?
It memoizes function results based on arguments. Great for expensive pure functions. The maxsize parameter controls cache size.
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # fast due to caching
print(fibonacci.cache_info()) # hits, misses, size
Part 4: Object-Oriented Programming (Questions 31-40)
31. What is the difference between class and instance variables?
Class variables are shared across all instances. Instance variables are unique per object. Be careful with mutable class variables.
class Dog:
species = "Canis familiaris" # class variable
def __init__(self, name):
self.name = name # instance variable
d1 = Dog("Rex")
d2 = Dog("Buddy")
print(d1.species == d2.species) # True (shared)
print(d1.name != d2.name) # True (unique)
32. Explain inheritance and method resolution order (MRO)
Python supports multiple inheritance. MRO determines method lookup order, computed using C3 linearization. Inspect with ClassName.__mro__ or ClassName.mro().
class A:
def greet(self):
return "A"
class B(A):
def greet(self):
return "B"
class C(A):
def greet(self):
return "C"
class D(B, C):
pass
print(D.mro()) # [D, B, C, A, object]
print(D().greet()) # "B"
33. What is super() and why use it?
super() calls the next class in the MRO. It's essential for cooperative multiple inheritance and proper initialization in inheritance chains.
class Base:
def __init__(self):
print("Base init")
class Child(Base):
def __init__(self):
super().__init__() # ensures Base is initialized
print("Child init")
Child()
# Base init
# Child init
34. What are abstract base classes (ABCs)?
ABCs define interfaces that subclasses must implement. Use abc.ABC and @abstractmethod.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
def perimeter(self):
return 2 * 3.14 * self.radius
# Shape() # TypeError - can't instantiate abstract class
c = Circle(5)
print(c.area())
35. What is the difference between composition and inheritance?
Inheritance models "is-a" relationships. Composition models "has-a" relationships. Prefer composition for flexibilityâinheritance creates tight coupling.
# Composition (preferred for flexibility)
class Engine:
def start(self):
return "Engine running"
class Car:
def __init__(self):
self.engine = Engine() # has-a
def start(self):
return self.engine.start()
36. What are dataclasses and why use them?
@dataclass auto-generates __init__, __repr__, and __eq__ for classes that primarily store data.
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
tags: list = field(default_factory=list)
p1 = Product("Laptop", 999.99)
p2 = Product("Laptop", 999.99)
print(p1 == p2) # True - auto-generated __eq__
print(p1) # Product(name='Laptop', price=999.99, tags=[])
37. What are properties and when to use them?
Properties allow controlled access to instance attributes with getter/setter logic while maintaining attribute syntax.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = 30 # uses setter
38. What is a metaclass?
A metaclass is a class whose instances are classes. type is the default metaclass. Use metaclasses for framework-level customization like enforcing class conventions.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
pass
db1 = Database()
db2 = Database()
print(db1 is db2) # True
39. What is duck typing?
Python doesn't check types explicitlyâit checks for the presence of methods/attributes. "If it walks like a duck and quacks like a duck, it's a duck."
def make_sound(animal):
animal.speak() # works for any object with speak()
class Dog:
def speak(self): print("Woof")
class Cat:
def speak(self): print("Meow")
make_sound(Dog()) # Woof
make_sound(Cat()) # Meow
40. How do you implement operator overloading?
Define dunder methods like __add__, __lt__, __len__ to enable operators on custom objects.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 == Vector(1, 2)) # True
Part 5: Advanced Topics (Questions 41-50)
41. What is the difference between threading and multiprocessing?
Threading runs multiple threads in one process, sharing memoryâgood for I/O-bound tasks but limited by the GIL. Multiprocessing runs separate processes with independent memoryâgood for CPU-bound tasks.
from multiprocessing import Pool
from concurrent.futures import ThreadPoolExecutor
import requests
# I/O-bound: threading
urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(requests.get, urls))
# CPU-bound: multiprocessing
def square(n):
return n ** 2
if __name__ == "__main__":
with Pool(4) as pool:
print(pool.map(square, range(10)))
42. What are context managers and how do you create one?
Context managers handle setup and teardown via with. Create them with a class (__enter__/__exit__) or contextlib.contextmanager.
from contextlib import contextmanager
@contextmanager
def timer():
import time
start = time.time()
try:
yield
finally:
print(f"Elapsed: {time.time() - start:.2f}s")
with timer():
sum(range(1_000_000))
43. What is async/await and when to use it?
async/await enables concurrent I/O using a single thread with an event loop. Ideal for high-concurrency I/O work like web servers and API clients.
import asyncio
async def fetch_data(url):
await asyncio.sleep(1) # simulate I/O
return f"Data from {url}"
async def main():
tasks = [fetch_data(f"url{i}") for i in range(5)]
results = await asyncio.gather(*tasks)
print(results)
asyncio.run(main()) # completes in ~1s, not 5s
44. What are type hints and why use them?
Type hints improve readability and enable static analysis with tools like mypy. They're optional at runtime but valuable for maintainability.
from typing import Optional, List, Dict
def process_items(items: List[str]) -> Dict[str, int]:
return {item: len(item) for item in items}
def find_user(user_id: int) -> Optional[dict]:
if user_id == 1:
return {"id": 1, "name": "Alice"}
return None
45. How do you handle exceptions properly?
Catch specific exceptions, not bare except:. Use else for code that runs only if no exception occurred, and finally for cleanup.
def read_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print("Config not found, using defaults")
return "{}"
except PermissionError:
raise # re-raise unexpected errors
else:
print("Config loaded successfully")
finally:
print("Attempt completed")
read_config("config.json")
46. What is the difference between __str__ and __repr__?
__str__ provides a user-friendly string (used by print). __repr__ provides an unambiguous, developer-friendly representation, ideally valid Python to recreate the object.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __str__(self):
return f"Point at ({self.x}, {self.y})"
def __repr__(self):
return f"Point({self.x!r}, {self.y!r})"
p = Point(1, 2)
print(p) # Point at (1, 2) -> __str__
print(repr(p)) # Point(1, 2) -> __repr__
47. How do you create a custom iterator?
Implement __iter__ (returns self) and __next__ (returns next value or raises StopIteration).
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
for n in Countdown(5):
print(n) # 5, 4, 3, 2, 1
48. What is the with statement's __exit__ return value?
If __exit__ returns True, any exception raised inside the with block is suppressed. Returning False or None lets it propagate. Use suppression carefully.
class SuppressErrors:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is ValueError:
print("Suppressed ValueError")
return True # suppress
return False
with SuppressErrors():
raise ValueError("oops") # suppressed
print("Continues normally")
49. What are slots and why use them?
__slots__ restricts instance attributes to a fixed set, saving memory and preventing accidental attribute creation. Trade-off: no __dict__, so less flexibility.
class Point:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
# p.z = 3 # AttributeError - prevented
50. How do you profile and optimize Python code?
Use cProfile to find bottlenecks, timeit for micro-benchmarks, and optimize only after profiling. Common optimizations: use built-ins, generators, and algorithmic improvements.
import cProfile
import timeit
# Profile a function
def slow_function():
return sum(i**2 for i in range(100000))
cProfile.run('slow_function()')
# Benchmark alternatives
print(timeit.timeit("sum(i**2 for i in range(1000))", number=1000))
print(timeit.timeit("sum(map(lambda i: i**2, range(1000)))", number=1000))
Best Practices Summary
- Write readable code firstâoptimize only after profiling proves a need.
- Prefer composition over inheritance to reduce coupling.
- Use
functools.wrapsin every decorator to preserve metadata. - Always catch specific exceptions, never bare
except:. - Use type hints for public APIs and shared codebases.
- Leverage the standard library (
collections,itertools,functools) before writing custom solutions. - Prefer generators over lists for large or streaming data.
- Use
withstatements for all resource management. - Write docstrings and testsâmid-level developers are expected to ship maintainable code.
- Understand the GIL's implications before reaching for threading.
Conclusion
Mastering these 50 questions gives you a strong foundation for mid-level Python interviews. The key isn't memorizing answers but understanding the why behind each conceptâwhy the GIL exists, why composition beats inheritance, why generators save memory. Pair this knowledge with hands-on practice: write decorators, build context managers, profile real code, and implement design patterns. Interviewers at this level care as much about your reasoning and communication as your technical accuracy. Be ready to discuss trade-offs, admit when you don't know something, and demonstrate how you'd find the answer. With consistent practice and a genuine understanding of Python's design philosophy, you'll be well-equipped to tackle any mid-level Python interview with confidence.