← Back to DevBytes

Top 50 Python Interview Questions for Mid-Level Developers

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles