← Back to DevBytes

Top 50 Python Interview Questions for Senior Developers

Top 50 Python Interview Questions for Senior Developers

Preparing for a senior Python developer interview requires more than memorizing syntax. Interviewers expect deep understanding of internals, design decisions, performance trade-offs, and idiomatic Python. This tutorial walks through 50 essential questions grouped by topic, with practical code examples and best practices to help you reason like a seasoned engineer.

Part 1: Core Python Concepts

1. What is the difference between is and ==?

== checks value equality, while is checks identity (whether two references point to the same object in memory).

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b)  # True - same values
print(a is b)  # False - different objects

c = a
print(a is c)  # True - same object

Best practice: Use is only for comparing with None, True, False, or sentinel objects. Use == for value comparisons.

2. Explain mutable vs immutable types

Immutable types (int, float, str, tuple, frozenset) cannot be changed after creation. Mutable types (list, dict, set) can be modified in place.

# Immutable - creates new object
s = "hello"
s += " world"  # new str object

# Mutable - modifies in place
lst = [1, 2, 3]
lst.append(4)  # same list object

This matters for hashing (only immutables are hashable), default arguments, and thread safety.

3. What is the GIL and why does it matter?

The Global Interpreter Lock (GIL) is a mutex in CPython that allows only one thread to execute Python bytecode at a time. It simplifies memory management (reference counting) but limits true parallelism for CPU-bound tasks.

# CPU-bound: threads won't help due to GIL
# Use multiprocessing instead
from multiprocessing import Pool

def square(x):
    return x * x

with Pool(4) as p:
    results = p.map(square, range(1000))

Best practice: Use threading for I/O-bound work, multiprocessing for CPU-bound work, and async for high-concurrency I/O.

4. How does Python's memory management work?

Python uses reference counting plus a cyclic garbage collector. Each object has a refcount; when it reaches zero, memory is freed. The GC handles reference cycles.

import sys
import gc

a = [1, 2, 3]
print(sys.getrefcount(a))  # 2 (a + getrefcount's arg)

# Cyclic reference
gc.disable()  # disable cyclic GC
gc.collect()  # manually run collection

5. What is 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 needed for immutables and singletons.

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

6. Explain the difference between shallow and deep copy

import copy

original = [[1, 2], [3, 4]]

shallow = copy.copy(original)
shallow[0][0] = 99
print(original)  # [[99, 2], [3, 4]] - inner list shared

deep = copy.deepcopy(original)
deep[0][0] = 0
print(original)  # unchanged - fully independent

Shallow copy creates a new container but shares inner objects. Deep copy recursively copies all nested objects.

7. What are decorators and how do they work?

Decorators are functions that take a function (or class) and return a modified version, applied with the @ syntax.

import functools

def log_calls(func):
    @functools.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_calls
def greet(name):
    """Greet someone."""
    return f"Hello, {name}"

print(greet("Alice"))

Best practice: Always use functools.wraps to preserve metadata of the wrapped function.

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

class MyClass:
    count = 0

    def instance_method(self):
        return self  # access instance

    @classmethod
    def class_method(cls):
        cls.count += 1
        return cls  # access class

    @staticmethod
    def static_method(x):
        return x * 2  # no access to cls or self

Use @classmethod for alternative constructors and factory methods. Use @staticmethod for utility functions logically grouped with the class.

9. How do *args and **kwargs work?

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

func(1, 2, 3, name="Alice", age=30)
# args: (1, 2, 3)
# kwargs: {'name': 'Alice', 'age': 30}

# Unpacking
def add(a, b, c):
    return a + b + c

nums = [1, 2, 3]
print(add(*nums))

opts = {'a': 1, 'b': 2, 'c': 3}
print(add(**opts))

10. What is the MRO and how does C3 linearization work?

The Method Resolution Order defines the order in which base classes are searched. Python uses C3 linearization to ensure a consistent, monotonic ordering.

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

print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

Part 2: Data Structures & Built-ins

11. How do list, deque, and array differ?

Lists are dynamic arrays with O(n) insertion at the front. collections.deque is a doubly-linked list with O(1) operations at both ends. array.array stores typed, compact numeric data.

from collections import deque
from array import array

d = deque([1, 2, 3])
d.appendleft(0)   # O(1)
d.popleft()       # O(1)

arr = array('i', [1, 2, 3])  # C-style integers

12. When should you use set vs frozenset?

Use set for mutable collections with fast membership tests. Use frozenset when you need an immutable, hashable set (e.g., as dict keys or set elements).

fs = frozenset([1, 2, 3])
d = {fs: "value"}  # works - frozenset is hashable

13. Explain collections.defaultdict and Counter

from collections import defaultdict, Counter

# defaultdict - auto-creates missing keys
groups = defaultdict(list)
for name, dept in [("Alice", "Eng"), ("Bob", "Eng"), ("Carol", "Sales")]:
    groups[dept].append(name)

# Counter - frequency counting
words = "the cat sat on the mat the cat".split()
c = Counter(words)
print(c.most_common(2))  # [('the', 3), ('cat', 2)]

14. How does itertools improve your code?

from itertools import chain, groupby, islice, product

# Chain iterables
for x in chain([1, 2], [3, 4]):
    print(x)

# Group consecutive elements
data = [("a", 1), ("a", 2), ("b", 3)]
for key, group in groupby(data, key=lambda x: x[0]):
    print(key, list(group))

# Cartesian product
for combo in product([1, 2], ['x', 'y']):
    print(combo)

Best practice: Prefer itertools over manual loops for memory efficiency and readability.

15. What is the difference between map, filter, and list comprehensions?

nums = [1, 2, 3, 4, 5]

# map/filter - functional style
squared = list(map(lambda x: x**2, nums))
evens = list(filter(lambda x: x % 2 == 0, nums))

# Comprehensions - more Pythonic
squared = [x**2 for x in nums]
evens = [x for x in nums if x % 2 == 0]

# Generator expression - lazy
squared_gen = (x**2 for x in nums)

Best practice: Prefer comprehensions for readability. Use generator expressions for large datasets to save memory.

16. How do sorted() and list.sort() differ?

sorted() returns a new list and works on any iterable. list.sort() sorts in place and returns None.

data = [(3, 'c'), (1, 'a'), (2, 'b')]

# Sort by first element, then second
sorted_data = sorted(data, key=lambda x: (x[0], x[1]))

# Stable sort - preserves order of equal elements
words = ['apple', 'Banana', 'cherry', 'Avocado']
print(sorted(words, key=str.lower))

17. Explain dictionary ordering and the OrderedDict

Since Python 3.7, regular dicts maintain insertion order. OrderedDict is still useful for move_to_end, popitem(last=), and equality that considers order.

from collections import OrderedDict

od = OrderedDict([('a', 1), ('b', 2)])
od.move_to_end('a')  # move 'a' to the end
od.popitem(last=False)  # remove first item

18. What are dataclasses and when should you use them?

from dataclasses import dataclass, field
from typing import List

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

    def distance_to(self, other: 'Point') -> float:
        return ((self.x - other.x)**2 + (self.y - other.y)**2)**0.5

@dataclass
class Employee:
    name: str
    skills: List[str] = field(default_factory=list)

Use dataclasses to reduce boilerplate for data containers. Use frozen=True for immutability and slots=True (3.10+) for memory efficiency.

19. How do __slots__ work and when should you use them?

__slots__ restricts instance attributes to a fixed set, eliminating the per-instance __dict__ and saving memory.

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

p = Point(1, 2)
# p.z = 3  # AttributeError

Best practice: Use __slots__ when you have millions of instances. Be aware it breaks multiple inheritance with non-slotted classes.

20. What is the difference between __repr__ and __str__?

__repr__ should return an unambiguous, developer-focused string (ideally valid Python to recreate the object). __str__ returns a user-friendly string.

class Color:
    def __init__(self, r, g, b):
        self.r, self.g, self.b = r, g, b

    def __repr__(self):
        return f"Color({self.r}, {self.g}, {self.b})"

    def __str__(self):
        return f"rgb({self.r}, {self.g}, {self.b})"

Part 3: OOP & Design Patterns

21. Explain the difference between composition and inheritance

Inheritance models "is-a" relationships; composition models "has-a". Favor composition for flexibility and to avoid deep inheritance hierarchies.

# Composition over inheritance
class Engine:
    def start(self): return "vroom"

class Car:
    def __init__(self):
        self.engine = Engine()  # has-a

    def start(self):
        return self.engine.start()

22. How do abstract base classes (ABCs) work?

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    @abstractmethod
    def perimeter(self) -> float: ...

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

    def area(self):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius

# Shape()  # TypeError - can't instantiate abstract class

23. What is a mixin and how is it used?

A mixin is a class that provides methods to other classes without being intended as a standalone class. It promotes code reuse across unrelated classes.

class JsonMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class LogMixin:
    def log(self, msg):
        print(f"[{self.__class__.__name__}] {msg}")

class User(JsonMixin, LogMixin):
    def __init__(self, name):
        self.name = name

u = User("Alice")
print(u.to_json())
u.log("created")

24. Implement the Singleton pattern in Python

class Singleton:
    _instances = {}
    def __new__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__new__(cls)
        return cls._instances[cls]

# Alternative: module-level singleton
# config.py
class Config: ...
config = Config()  # single instance per module

Best practice: Often, a module-level instance or dependency injection is cleaner than the Singleton pattern.

25. What is the Factory pattern and how do you implement it?

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def speak(self): ...

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

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

class AnimalFactory:
    _registry = {'dog': Dog, 'cat': Cat}

    @classmethod
    def create(cls, kind: str) -> Animal:
        if kind not in cls._registry:
            raise ValueError(f"Unknown animal: {kind}")
        return cls._registry[kind]()

print(AnimalFactory.create('dog').speak())

26. How do you implement the Observer pattern?

from dataclasses import dataclass
from typing import Callable, List

class EventEmitter:
    def __init__(self):
        self._listeners: dict[str, List[Callable]] = {}

    def on(self, event: str, callback: Callable):
        self._listeners.setdefault(event, []).append(callback)

    def emit(self, event: str, *args, **kwargs):
        for cb in self._listeners.get(event, []):
            cb(*args, **kwargs)

emitter = EventEmitter()
emitter.on('greet', lambda name: print(f"Hi {name}"))
emitter.emit('greet', 'Alice')

27. Explain __enter__ and __exit__ (context managers)

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

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

# Or use contextlib
from contextlib import contextmanager

@contextmanager
def timer():
    import time
    start = time.time()
    try:
        yield
    finally:
        print(f"Elapsed: {time.time() - start:.2f}s")

28. What are descriptors and when would you use them?

Descriptors are objects that implement __get__, __set__, or __delete__. They power properties, classmethods, and staticmethods.

class Validated:
    def __init__(self, min_val, max_val):
        self.min_val = min_val
        self.max_val = max_val

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        if not (self.min_val <= value <= self.max_val):
            raise ValueError(f"{self.name} must be in range")
        obj.__dict__[self.name] = value

class Product:
    price = Validated(0, 10000)
    def __init__(self, price):
        self.price = price

29. How does super() work in multiple inheritance?

super() follows the MRO, not just the immediate parent. This ensures cooperative multiple inheritance works correctly.

class Base:
    def __init__(self):
        print("Base.__init__")

class A(Base):
    def __init__(self):
        print("A.__init__ before")
        super().__init__()
        print("A.__init__ after")

class B(Base):
    def __init__(self):
        print("B.__init__ before")
        super().__init__()
        print("B.__init__ after")

class C(A, B):
    def __init__(self):
        print("C.__init__ before")
        super().__init__()
        print("C.__init__ after")

C()  # Follows MRO: C -> A -> B -> Base

30. What is duck typing and how does it relate to Python's philosophy?

Duck typing means you don't check types but rely on the presence of methods/attributes. "If it walks like a duck and quacks like a duck, it's a duck."

def make_sound(animal):
    # No type check - just call the method
    return animal.speak()

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

class Robot:
    def speak(self): return "Beep"

make_sound(Dog())    # "Woof"
make_sound(Robot())  # "Beep"

Modern Python combines duck typing with optional type hints and typing.Protocol for structural subtyping.

Part 4: Concurrency & Async

31. Threading vs multiprocessing vs asyncio — when to use each?

# Threading for I/O
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch_url, urls))

# Multiprocessing for CPU
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
    results = list(pool.map(heavy_compute, data))

32. How does asyncio work?

import asyncio

async def fetch_data(url):
    await asyncio.sleep(1)  # simulate I/O
    return f"data from {url}"

async def main():
    # Run concurrently
    results = await asyncio.gather(
        fetch_data("url1"),
        fetch_data("url2"),
        fetch_data("url3"),
    )
    print(results)

asyncio.run(main())

Best practice: Never call blocking I/O inside async functions. Use asyncio.to_thread() or run_in_executor() for blocking calls.

33. What is the difference between asyncio.gather and asyncio.wait?

import asyncio

async def task(n):
    await asyncio.sleep(n)
    return n

async def main():
    # gather - returns results in order, raises on first exception
    results = await asyncio.gather(task(1), task(2), task(3))

    # wait - more control, returns (done, pending) sets
    done, pending = await asyncio.wait(
        [task(1), task(2), task(3)],
        return_when=asyncio.FIRST_COMPLETED,
        timeout=2.0,
    )

34. How do you handle shared state in concurrent programs?

from threading import Lock
from multiprocessing import Manager

# Threading - use locks
counter = 0
lock = Lock()

def increment():
    global counter
    with lock:
        counter += 1

# Multiprocessing - use Manager
with Manager() as manager:
    shared_dict = manager.dict()
    shared_list = manager.list()

35. What are asyncio.Queue and producer-consumer patterns?

import asyncio

async def producer(queue, items):
    for item in items:
        await queue.put(item)
    await queue.put(None)  # sentinel

async def consumer(queue):
    while True:
        item = await queue.get()
        if item is None:
            break
        print(f"Processing {item}")
        queue.task_done()

async def main():
    q = asyncio.Queue(maxsize=10)
    await asyncio.gather(
        producer(q, range(5)),
        consumer(q),
    )

asyncio.run(main())

Part 5: Memory Management & Performance

36. How do you profile Python code?

# cProfile
import cProfile
cProfile.run('my_function()', sort='cumulative')

# Line profiler
# pip install line_profiler
# @profile decorator, then: kernprof -l script.py

# Memory profiler
# pip install memory_profiler
from memory_profiler import profile

@profile
def my_func():
    data = [i for i in range(1000000)]
    return sum(data)

# timeit for micro-benchmarks
import timeit
timeit.timeit('sum(range(100))', number=10000)

37. What are generators and how do they save memory?

# Generator function - lazy evaluation
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]

# Generator expression
squares = (x**2 for x in range(10**9))  # no memory issue

# Pipeline with generators
def read_lines(path):
    with open(path) as f:
        yield from f

lines = (l.strip() for l in read_lines('data.txt'))
words = (w for l in lines for w in l.split())

38. Explain yield from and generator delegation

def sub_gen():
    yield 1
    yield 2
    yield 3

def main_gen():
    yield 'start'
    yield from sub_gen()  # delegates to sub_gen
    yield 'end'

print(list(main_gen()))
# ['start', 1, 2, 3, 'end']

# Also enables bidirectional communication
def echo():
    while True:
        received = yield
        print(f"Got: {received}")

39. How do you optimize memory usage in Python?

# 1. Use generators instead of lists
sum(x**2 for x in range(10**6))  # vs list comprehension

# 2. Use __slots__ for many instances
class Point:
    __slots__ = ('x', 'y')

# 3. Use array for numeric data
from array import array
nums = array('d', [1.0, 2.0, 3.0])  # compact doubles

# 4. Use __init_subclass__ to avoid metaclass complexity
# 5. Intern frequently-used strings
import sys
s = sys.intern("frequently_used_string")

40. What is the difference between __del__ and context managers for cleanup?

__del__ is unreliable — it depends on GC, may not run at exit, and can cause issues with cycles. Context managers (with) provide deterministic cleanup.

# Bad - relies on __del__
class BadResource:
    def __del__(self):
        self.close()  # may not run

# Good - deterministic cleanup
class GoodResource:
    def __enter__(self):
        self.open()
        return self

    def __exit__(self, *exc):
        self.close()
        return False

with GoodResource() as r:
    use(r)

Part 6: Type Hints & Modern Python

41. How do type hints work and what is typing?

from typing import Optional, Union, List, Dict, Callable, TypeVar, Generic

T = TypeVar('T')

def first(items: list[T]) -> Optional[T]:
    return items[0] if items else None

def process(
    data: dict[str, list[int]],
    callback: Callable[[int], str],
) -> str:
    return callback(data['values'][0])

# Python 3.10+ union syntax
def parse(x: int | str) -> int | None:
    try:
        return int(x)
    except (ValueError, TypeError):
        return None

42. What are Protocol and structural subtyping?

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def cleanup(resource: SupportsClose) -> None:
    resource.close()  # any object with close() works

class FileLike:
    def close(self) -> None:
        print("closed")

cleanup(FileLike())  # No inheritance needed

43. How do you use TypedDict?

from typing import TypedDict

class UserDict(TypedDict):
    name: str
    age: int
    email: str | None

user: UserDict = {'name': 'Alice', 'age': 30, 'email': None}

# Required and not-required keys (3.11+)
from typing import NotRequired
class PartialUser(TypedDict):
    name: str
    age: NotRequired[int]

44. What is functools and its key utilities?

from functools import lru_cache, partial, reduce, singledispatch

# Caching
@lru_cache(maxsize=128)
def expensive(x):
    return x ** 2

# Partial application
def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
print(square(5))  # 25

# Single dispatch
@singledispatch
def process(data):
    raise TypeError("Unsupported")

@process.register
def _(data: str):
    return data.upper()

@process.register
def _(data: list):
    return [process(x) for x in data]

45. Explain __init_subclass__ and its use cases

class Plugin:
    registry = {}

    def __init_subclass__(cls, plugin_name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if plugin_name:
            Plugin.registry[plugin_name] = cls

class CSVLoader(Plugin, plugin_name='csv'):
    pass

class JSONLoader(Plugin, plugin_name='json'):
    pass

print(Plugin.registry)  # {'csv': CSVLoader, 'json': JSONLoader}

This is a cleaner alternative to metaclasses for plugin registration and class customization.

Part 7: Testing, Debugging & Best Practices

46. How do you write effective unit tests with pytest?

# test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calc():
    return Calculator()

class TestCalculator:
    def test_add(self, calc):
        assert calc.add(2, 3) == 5

    @pytest.mark.parametrize("a,b,expected", [
        (1, 2, 3),
        (-1, 1, 0),
        (0, 0, 0),
    ])
    def test_add_parametrized(self, calc, a, b, expected):
        assert calc.add(a, b) == expected

    def test_divide_by_zero(self, calc):
        with pytest.raises(ZeroDivisionError):
            calc.divide(1, 0)

Best practice: Use fixtures for setup, parametrize for data-driven tests, and test both happy and edge cases.

47. How do you mock dependencies in tests?

from unittest.mock import patch, MagicMock
import pytest

def fetch_user_data(user_id):
    # calls external API
    ...

class UserService:
    def __init__(self, fetcher):
        self.fetcher = fetcher

    def get_name(self, user_id):
        data = self.fetcher(user_id)
        return data.get('name', 'Unknown')

def test_get_name():
    mock_fetcher = MagicMock(return_value={'name': 'Alice'})
    service = UserService(mock_fetcher)
    assert service.get_name(1) == 'Alice'
    mock_fetcher.assert_called_once_with(1)

# Patch at module level
@patch('mymodule.requests.get')
def test_api_call(mock_get):
    mock_get.return_value.json.return_value = {'status': 'ok'}
    result = my_module.call_api()
    assert result['status'] == 'ok'

48. What are common Python anti-patterns to avoid?

# 1. Mutable default arguments
def bad(items=[]):  # shared across calls!
    items.append(1)
    return items

def good(items=None):
    if items is None:
        items = []
    items.append(1)
    return items

# 2. Bare except
try:
    do_something()
except:  # catches SystemExit, KeyboardInterrupt
    pass

# Good
except Exception as e:
    logger.exception(e)

# 3. Using == for None
if x == None:  # bad
if x is None:  # good

# 4. Not using context managers
f = open('file.txt')  # may not close on exception
# Good
with open('file.txt') as f:
    data = f.read()

49. How do you structure a Python project for maintainability?

my_project/
├── pyproject.toml        # build config, dependencies
├── src/
│   └── my_package/
│       ├── __init__.py
│       ├── core.py
│       ├── models.py
│       └── utils.py
├── tests/
│   ├── conftest.py
│   ├── test_core.py
│   └── test_models.py
├── docs/
└── .github/workflows/    # CI/CD

Best practices: Use src/ layout to avoid import issues, pyproject.toml for modern packaging, separate concerns into modules, and maintain comprehensive tests with CI.

50. How do you handle errors and exceptions properly?

import logging
from typing import Optional

logger = logging.getLogger(__name__)

class DataNotFoundError(Exception):
    """Raised when requested data doesn't exist."""
    pass

def get_user(user_id: int) -> dict:
    user = db.find(user_id)
    if user is None:
        raise DataNotFoundError(f"User {user_id} not found")
    return user

def safe_get_user(user_id: int) -> Optional[dict]:
    try:

— Ad —

Google AdSense will appear here after approval

← Back to all articles