Top 50 Python Interview Questions and Answers for 2026

Python interviews in 2026 test more than your ability to write a for loop. Employers want to see that you understand the language deeply, that you can reason about performance and design, and that you have practical experience with the libraries that matter in production. Whether you are interviewing for a data analyst position, a backend engineering role, or a machine learning job, the questions below represent what you will actually face.

This guide organizes 50 questions into six categories, progressing from fundamentals to advanced topics. Each question includes a concise answer and, where applicable, a code example you can run yourself. Read through them all, but spend extra time on the categories most relevant to the role you are targeting.

Basics (Questions 1-10)

1. What are Python's core data types?

Python has several built-in data types organized into categories.

Numeric types: int, float, complex Sequence types: list, tuple, range, str Mapping type: dict Set types: set, frozenset Boolean type: bool Binary types: bytes, bytearray, memoryview None type: NoneType

Interviewers ask this to confirm you understand the full landscape of types, not just the ones you use daily.

2. What is the difference between mutable and immutable objects?

Mutable objects can be changed after creation. Lists, dictionaries, and sets are mutable. Immutable objects cannot be changed after creation. Strings, tuples, integers, and frozensets are immutable.

# Mutable: modifying a list in place
my_list = [1, 2, 3]
my_list[0] = 99
print(my_list)  # [99, 2, 3]

# Immutable: strings cannot be modified in place
my_string = "hello"
# my_string[0] = "H"  # TypeError: 'str' object does not support item assignment
my_string = "H" + my_string[1:]  # Creates a new string
print(my_string)  # "Hello"

Understanding mutability is critical because it affects how objects behave when passed to functions and when used as dictionary keys.

3. What is the difference between a list and a tuple?

  1. Lists are mutable; tuples are immutable.
  2. Lists use square brackets []; tuples use parentheses ().
  3. Tuples are hashable (if their elements are hashable) and can be used as dictionary keys; lists cannot.
  4. Tuples are slightly faster and use less memory than lists.
  5. Tuples signal intent: they represent fixed collections of heterogeneous data, while lists represent variable-length collections of homogeneous data.
coordinates = (40.7128, -74.0060)  # Tuple: fixed pair of values
temperatures = [72, 75, 68, 71]    # List: variable collection of similar values

4. How does dictionary comprehension work?

A dictionary comprehension creates a dictionary from an iterable using a concise one-line syntax.

# Basic dictionary comprehension
squares = {x: x**2 for x in range(1, 6)}
print(squares)  # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# With a conditional filter
even_squares = {x: x**2 for x in range(1, 11) if x % 2 == 0}
print(even_squares)  # {2: 4, 4: 16, 6: 36, 8: 64, 10: 100}

# Transforming an existing dictionary
prices = {"apple": 1.50, "banana": 0.75, "cherry": 2.00}
discounted = {item: round(price * 0.9, 2) for item, price in prices.items()}
print(discounted)  # {'apple': 1.35, 'banana': 0.68, 'cherry': 1.8}

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

== checks value equality. is checks identity, meaning whether two variables point to the exact same object in memory.

a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(a == b)  # True (same values)
print(a is b)  # False (different objects in memory)
print(a is c)  # True (same object)

A common use of is is checking for None: always write if x is None rather than if x == None.

6. What are *args and **kwargs?

*args allows a function to accept any number of positional arguments as a tuple. **kwargs allows any number of keyword arguments as a dictionary.

def log_message(level, *args, **kwargs):
    print(f"[{level}]", *args)
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

log_message("INFO", "User logged in", user="alice", ip="192.168.1.1")
# [INFO] User logged in
#   user: alice
#   ip: 192.168.1.1

7. How does Python's garbage collection work?

Python uses reference counting as its primary memory management mechanism. Every object maintains a count of how many references point to it. When that count drops to zero, the memory is freed immediately.

For circular references (where two or more objects reference each other), Python has a cyclic garbage collector that periodically detects and cleans up reference cycles. You can interact with it through the gc module.

import gc

# Force a garbage collection cycle
gc.collect()

# Check if garbage collection is enabled
print(gc.isenabled())  # True

8. What is a Python virtual environment and why use one?

A virtual environment is an isolated Python installation that has its own set of installed packages. It prevents dependency conflicts between projects.

# Create a virtual environment
# python -m venv myproject_env

# Activate it (Linux/Mac)
# source myproject_env/bin/activate

# Activate it (Windows)
# myproject_env\Scripts\activate

# Install packages into the isolated environment
# pip install pandas==2.2.0

Without virtual environments, installing a package for one project can break another project that depends on a different version of the same package.

9. What is the difference between deepcopy and shallow copy?

A shallow copy creates a new object but inserts references to the same nested objects. A deep copy creates a new object and recursively copies all nested objects.

import copy

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

shallow = copy.copy(original)
deep = copy.deepcopy(original)

original[0][0] = 99

print(shallow[0][0])  # 99 (affected because inner list is shared)
print(deep[0][0])     # 1  (unaffected because inner list was copied)

10. What are Python's string formatting methods?

Python offers three primary string formatting approaches.

name = "Alice"
age = 30

# 1. f-strings (recommended in modern Python)
print(f"{name} is {age} years old")

# 2. str.format() method
print("{} is {} years old".format(name, age))

# 3. % operator (legacy, avoid in new code)
print("%s is %d years old" % (name, age))

f-strings are the preferred approach in 2026. They are the most readable, the fastest, and they support arbitrary expressions inside the braces.

Object-Oriented Programming (Questions 11-18)

11. How do you define a class in Python?

class Employee:
    company = "Acme Corp"  # Class attribute (shared by all instances)

    def __init__(self, name, salary):
        self.name = name        # Instance attribute
        self.salary = salary    # Instance attribute

    def annual_salary(self):
        return self.salary * 12

    def __repr__(self):
        return f"Employee(name='{self.name}', salary={self.salary})"

emp = Employee("Alice", 8000)
print(emp.annual_salary())  # 96000
print(emp)                  # Employee(name='Alice', salary=8000)

12. Explain inheritance and method resolution order (MRO).

Inheritance allows a class to inherit attributes and methods from a parent class. Python supports multiple inheritance, and the Method Resolution Order (MRO) determines which method is called when multiple parent classes define the same method.

class Animal:
    def speak(self):
        return "..."

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

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

class DogCat(Dog, Cat):
    pass

pet = DogCat()
print(pet.speak())       # "Woof" (Dog comes first in MRO)
print(DogCat.__mro__)
# (DogCat, Dog, Cat, Animal, object)

Python uses the C3 linearization algorithm to compute MRO, ensuring a consistent and predictable order.

13. What are dunder (magic) methods?

Dunder methods (double underscore methods) are special methods that Python calls implicitly in response to certain operations.

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __len__(self):
        return int((self.x**2 + self.y**2)**0.5)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)    # Vector(4, 6)
print(len(v1))    # 5
print(v1 == v2)   # False

Common dunder methods include __init__, __repr__, __str__, __eq__, __lt__, __len__, __getitem__, __setitem__, and __hash__.

14. How do decorators work?

A decorator is a function that takes another function as input and returns a modified version of that function. Decorators use the @ syntax.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def slow_function(n):
    total = sum(range(n))
    return total

result = slow_function(10_000_000)
# slow_function took 0.2314 seconds

Decorators are used extensively in frameworks (Flask routes, Django views) and for cross-cutting concerns like logging, caching, authentication, and retry logic.

15. What is the difference between @staticmethod and @classmethod?

  1. @staticmethod does not receive any implicit first argument. It behaves like a regular function that happens to live inside a class.
  2. @classmethod receives the class itself as the first argument (cls), not an instance. It is commonly used for alternative constructors.
class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def from_string(cls, date_string):
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)

    @staticmethod
    def is_valid(date_string):
        parts = date_string.split("-")
        return len(parts) == 3 and all(p.isdigit() for p in parts)

print(Date.is_valid("2026-03-14"))  # True
d = Date.from_string("2026-03-14")
print(d.year)  # 2026

16. What are abstract base classes?

Abstract base classes (ABCs) define a contract that subclasses must fulfill. You cannot instantiate an abstract class directly.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

# shape = Shape()  # TypeError: Can't instantiate abstract class
rect = Rectangle(5, 3)
print(rect.area())  # 15

17. How does Python's property decorator work?

The @property decorator lets you define methods that are accessed like attributes, enabling controlled access to instance data.

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

temp = Temperature(25)
print(temp.fahrenheit)  # 77.0
temp.celsius = 100
print(temp.fahrenheit)  # 212.0

18. What is the difference between composition and inheritance?

Inheritance models an "is-a" relationship: a Dog is an Animal. Composition models a "has-a" relationship: a Car has an Engine.

# Composition (preferred in most cases)
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return "Engine running"

class Car:
    def __init__(self, make, engine):
        self.make = make
        self.engine = engine  # Composition: Car HAS an Engine

    def start(self):
        return f"{self.make}: {self.engine.start()}"

engine = Engine(200)
car = Car("Toyota", engine)
print(car.start())  # Toyota: Engine running

Modern Python practice favors composition over inheritance because it produces more flexible and testable code.

Data Structures and Algorithms (Questions 19-26)

19. What is Big O notation and why does it matter?

Big O notation describes how an algorithm's runtime or memory usage scales with input size. It expresses the worst-case growth rate.

  1. O(1) — Constant time. Dictionary lookups, array index access.
  2. O(log n) — Logarithmic. Binary search.
  3. O(n) — Linear. Iterating through a list.
  4. O(n log n) — Linearithmic. Efficient sorting algorithms like merge sort and Timsort.
  5. O(n^2) — Quadratic. Nested loops over the same collection.
  6. O(2^n) — Exponential. Recursive algorithms without memoization for problems like Fibonacci.

Interviewers ask this because writing code that works is not enough. Code that works efficiently at scale is what matters in production.

20. How does Python's built-in sort work?

Python uses Timsort, a hybrid sorting algorithm derived from merge sort and insertion sort. It has:

  1. Best case: O(n) for nearly sorted data
  2. Average case: O(n log n)
  3. Worst case: O(n log n)
  4. Space complexity: O(n)
# sort() modifies the list in place, returns None
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
numbers.sort()
print(numbers)  # [1, 1, 2, 3, 4, 5, 6, 9]

# sorted() returns a new sorted list, original unchanged
data = [3, 1, 4, 1, 5]
result = sorted(data, reverse=True)
print(result)  # [5, 4, 3, 1, 1]

# Custom sort with key function
words = ["banana", "apple", "cherry"]
words.sort(key=len)
print(words)  # ['apple', 'banana', 'cherry']

21. Implement binary search in Python.

def binary_search(arr, target):
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1  # Not found

sorted_list = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print(binary_search(sorted_list, 23))  # 5
print(binary_search(sorted_list, 10))  # -1

Binary search requires a sorted input and runs in O(log n) time, making it dramatically faster than linear search for large datasets.

22. Explain recursion with an example.

Recursion is when a function calls itself. Every recursive function needs a base case to prevent infinite recursion.

def factorial(n):
    if n <= 1:       # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case

print(factorial(5))  # 120 (5 * 4 * 3 * 2 * 1)

# Fibonacci with memoization
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))  # 12586269025

Without memoization, the naive Fibonacci implementation has O(2^n) time complexity. With memoization, it drops to O(n).

23. What is the difference between a stack and a queue?

  1. Stack: Last In, First Out (LIFO). Think of a stack of plates.
  2. Queue: First In, First Out (FIFO). Think of a line at a store.
# Stack using a list
stack = []
stack.append("a")
stack.append("b")
stack.append("c")
print(stack.pop())  # "c" (last in, first out)

# Queue using collections.deque
from collections import deque
queue = deque()
queue.append("a")
queue.append("b")
queue.append("c")
print(queue.popleft())  # "a" (first in, first out)

Use collections.deque for queues because list.pop(0) is O(n), while deque.popleft() is O(1).

24. How do hash tables work and how does Python implement them?

A hash table stores key-value pairs using a hash function to compute an index into an array of buckets. Python's dict is implemented as a hash table.

# Dictionary operations are O(1) average case
lookup_table = {"alice": 85, "bob": 92, "charlie": 78}

# Lookup: O(1)
print(lookup_table["alice"])  # 85

# Insertion: O(1)
lookup_table["diana"] = 95

# Membership test: O(1)
print("bob" in lookup_table)  # True

Python dictionaries handle hash collisions using open addressing with a probing strategy. Keys must be hashable (immutable), which is why you cannot use a list as a dictionary key.

25. Implement a linked list in Python.

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def append(self, data):
        new_node = Node(data)
        if not self.head:
            self.head = new_node
            return
        current = self.head
        while current.next:
            current = current.next
        current.next = new_node

    def display(self):
        elements = []
        current = self.head
        while current:
            elements.append(current.data)
            current = current.next
        return elements

ll = LinkedList()
ll.append(10)
ll.append(20)
ll.append(30)
print(ll.display())  # [10, 20, 30]

26. What is the difference between BFS and DFS?

Breadth-First Search (BFS) explores all neighbors at the current depth before moving deeper. Depth-First Search (DFS) explores as far as possible along each branch before backtracking.

from collections import deque

graph = {
    "A": ["B", "C"],
    "B": ["D", "E"],
    "C": ["F"],
    "D": [], "E": [], "F": []
}

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    order = []
    while queue:
        node = queue.popleft()
        if node not in visited:
            visited.add(node)
            order.append(node)
            queue.extend(graph[node])
    return order

def dfs(graph, start, visited=None):
    if visited is None:
        visited = []
    visited.append(start)
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)
    return visited

print(bfs(graph, "A"))  # ['A', 'B', 'C', 'D', 'E', 'F']
print(dfs(graph, "A"))  # ['A', 'B', 'D', 'E', 'C', 'F']

BFS uses a queue and is ideal for finding shortest paths. DFS uses a stack (or recursion) and is better for exploring all possibilities.

Pandas and NumPy (Questions 27-34)

27. How do you create a DataFrame and perform basic operations?

import pandas as pd

# Create from a dictionary
df = pd.DataFrame({
    "name": ["Alice", "Bob", "Charlie", "Diana"],
    "department": ["Engineering", "Marketing", "Engineering", "Marketing"],
    "salary": [95000, 72000, 88000, 76000]
})

# Basic operations
print(df.shape)          # (4, 3)
print(df.dtypes)         # Column data types
print(df.describe())     # Summary statistics
print(df.head(2))        # First 2 rows

# Filtering
engineers = df[df["department"] == "Engineering"]
high_earners = df[df["salary"] > 80000]

28. Explain groupby operations in pandas.

groupby splits data into groups, applies a function to each group, and combines the results.

import pandas as pd

df = pd.DataFrame({
    "department": ["Eng", "Mkt", "Eng", "Mkt", "Eng"],
    "quarter": ["Q1", "Q1", "Q2", "Q2", "Q1"],
    "revenue": [100, 80, 120, 90, 110]
})

# Single aggregation
print(df.groupby("department")["revenue"].mean())
# Eng    110.0
# Mkt     85.0

# Multiple aggregations
summary = df.groupby("department")["revenue"].agg(["mean", "sum", "count"])
print(summary)

# Group by multiple columns
detail = df.groupby(["department", "quarter"])["revenue"].sum()
print(detail)

29. How do you merge DataFrames?

Pandas offers multiple ways to combine DataFrames, analogous to SQL joins.

import pandas as pd

employees = pd.DataFrame({
    "emp_id": [1, 2, 3, 4],
    "name": ["Alice", "Bob", "Charlie", "Diana"]
})

salaries = pd.DataFrame({
    "emp_id": [1, 2, 3, 5],
    "salary": [95000, 72000, 88000, 65000]
})

# Inner join (only matching rows)
inner = pd.merge(employees, salaries, on="emp_id", how="inner")

# Left join (all employees, salary where available)
left = pd.merge(employees, salaries, on="emp_id", how="left")

# Outer join (all rows from both)
outer = pd.merge(employees, salaries, on="emp_id", how="outer")

print(left)
#    emp_id     name    salary
# 0       1    Alice   95000.0
# 1       2      Bob   72000.0
# 2       3  Charlie   88000.0
# 3       4    Diana       NaN

30. How do you handle missing data in pandas?

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "name": ["Alice", "Bob", None, "Diana"],
    "score": [85, np.nan, 72, np.nan]
})

# Detect missing values
print(df.isnull().sum())
# name     1
# score    2

# Drop rows with any missing values
cleaned = df.dropna()

# Fill missing values
filled = df.fillna({"name": "Unknown", "score": df["score"].mean()})

# Forward fill (use previous valid value)
df["score"] = df["score"].ffill()

# Interpolation
df["score"] = df["score"].interpolate(method="linear")

Interviewers ask this because real-world data is always messy. How you handle missing values directly impacts the quality of your analysis.

31. What is the difference between loc and iloc?

loc selects by label (row/column names). iloc selects by integer position.

import pandas as pd

df = pd.DataFrame(
    {"A": [10, 20, 30], "B": [40, 50, 60]},
    index=["x", "y", "z"]
)

print(df.loc["x", "A"])    # 10 (by label)
print(df.iloc[0, 0])       # 10 (by position)
print(df.loc["x":"y"])     # Rows x and y (inclusive)
print(df.iloc[0:2])        # Rows 0 and 1 (exclusive end)

32. How do you create and manipulate NumPy arrays?

import numpy as np

# Create arrays
arr = np.array([1, 2, 3, 4, 5])
zeros = np.zeros((3, 4))
randoms = np.random.randn(3, 3)

# Vectorized operations (no loops needed)
print(arr * 2)        # [2, 4, 6, 8, 10]
print(arr ** 2)       # [1, 4, 9, 16, 25]
print(np.sqrt(arr))   # [1.0, 1.414, 1.732, 2.0, 2.236]

# Reshaping
matrix = np.arange(12).reshape(3, 4)
print(matrix.shape)   # (3, 4)

# Boolean indexing
print(arr[arr > 3])   # [4, 5]

NumPy is faster than pure Python lists because it stores data in contiguous memory blocks and uses optimized C implementations for operations.

33. How do you apply custom functions to a DataFrame?

import pandas as pd

df = pd.DataFrame({
    "name": ["Alice Smith", "Bob Jones"],
    "salary": [95000, 72000]
})

# apply() on a column (Series)
df["last_name"] = df["name"].apply(lambda x: x.split()[-1])

# apply() on a row
df["tax"] = df.apply(lambda row: row["salary"] * 0.3 if row["salary"] > 80000 else row["salary"] * 0.2, axis=1)

# map() for element-wise transformation
df["salary_grade"] = df["salary"].map(lambda x: "Senior" if x > 80000 else "Junior")

# applymap() for element-wise on entire DataFrame (use map() in pandas 2.1+)
numeric_df = df[["salary", "tax"]]
formatted = numeric_df.map(lambda x: f"${x:,.0f}")
print(formatted)

34. How do you pivot and reshape data in pandas?

import pandas as pd

df = pd.DataFrame({
    "date": ["2026-01", "2026-01", "2026-02", "2026-02"],
    "product": ["A", "B", "A", "B"],
    "sales": [100, 150, 120, 180]
})

# Pivot table
pivot = df.pivot_table(values="sales", index="date", columns="product", aggfunc="sum")
print(pivot)
# product      A    B
# date
# 2026-01    100  150
# 2026-02    120  180

# Melt (unpivot): wide to long format
melted = pivot.reset_index().melt(id_vars="date", var_name="product", value_name="sales")
print(melted)

Error Handling and Testing (Questions 35-42)

35. Explain try/except/else/finally.

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    except TypeError as e:
        print(f"Invalid types: {e}")
        return None
    else:
        print("Division successful")  # Runs only if no exception
        return result
    finally:
        print("This always runs")  # Cleanup code

print(divide(10, 2))   # Division successful -> This always runs -> 5.0
print(divide(10, 0))   # Cannot divide by zero -> This always runs -> None

The else block runs only when no exception occurs. The finally block always runs, making it ideal for cleanup operations like closing files or database connections.

36. How do you create custom exceptions?

class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw ${amount}. Balance: ${balance}"
        )

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)
        self.balance -= amount
        return self.balance

account = BankAccount(100)
try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(e)  # Cannot withdraw $150. Balance: $100

37. What is the difference between assertions and exceptions?

Assertions check for conditions that should never occur if the code is correct. They are debugging aids. Exceptions handle expected runtime errors.

# Assertions: catch programming errors
def calculate_average(numbers):
    assert len(numbers) > 0, "Cannot average an empty list"
    return sum(numbers) / len(numbers)

# Exceptions: handle expected runtime issues
def read_config(filepath):
    try:
        with open(filepath) as f:
            return f.read()
    except FileNotFoundError:
        return default_config()

Assertions can be disabled with python -O, so never use them for input validation or security checks.

38. How do you write tests with pytest?

# test_calculator.py
import pytest

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# Basic test
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

# Testing exceptions
def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

# Parametrized tests
@pytest.mark.parametrize("a,b,expected", [
    (10, 2, 5),
    (9, 3, 3),
    (7, 2, 3.5),
])
def test_divide(a, b, expected):
    assert divide(a, b) == expected

Run tests with pytest test_calculator.py -v.

39. What are fixtures in pytest?

Fixtures provide reusable setup and teardown logic for tests.

import pytest

@pytest.fixture
def sample_database():
    db = {"users": [{"name": "Alice", "age": 30}]}
    yield db  # Test runs here
    db.clear()  # Teardown

@pytest.fixture
def api_client():
    client = create_test_client()
    yield client
    client.close()

def test_user_count(sample_database):
    assert len(sample_database["users"]) == 1

def test_add_user(sample_database):
    sample_database["users"].append({"name": "Bob", "age": 25})
    assert len(sample_database["users"]) == 2

40. How do you use mocking in Python tests?

from unittest.mock import patch, MagicMock

def get_user_data(user_id):
    import requests
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

@patch("requests.get")
def test_get_user_data(mock_get):
    mock_get.return_value = MagicMock(
        json=lambda: {"name": "Alice", "id": 1}
    )

    result = get_user_data(1)
    assert result["name"] == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/1")

Mocking replaces real objects with controlled substitutes, allowing you to test code in isolation without hitting external services.

41. What is test coverage and how do you measure it?

Test coverage measures what percentage of your code is executed during tests.

# Run with coverage
# pytest --cov=myproject --cov-report=html tests/

# Example output:
# Name                  Stmts   Miss  Cover
# -----------------------------------------
# myproject/core.py        50      5    90%
# myproject/utils.py       30      0   100%
# -----------------------------------------
# TOTAL                    80      5    94%

Aim for 80-90% coverage in production code. 100% coverage does not guarantee correctness, but low coverage almost guarantees bugs.

42. How do you handle multiple exceptions?

# Catching multiple exception types
try:
    value = int(input("Enter a number: "))
    result = 100 / value
except (ValueError, ZeroDivisionError) as e:
    print(f"Error: {e}")

# Exception chaining
try:
    data = fetch_data()
except ConnectionError as e:
    raise RuntimeError("Failed to load data") from e

# Exception groups (Python 3.11+)
try:
    results = process_batch(items)
except* ValueError as eg:
    print(f"Value errors: {eg.exceptions}")
except* TypeError as eg:
    print(f"Type errors: {eg.exceptions}")

Advanced Topics (Questions 43-50)

43. What are generators and how do they work?

Generators are functions that yield values one at a time instead of returning them all at once. They are memory-efficient for large datasets.

def fibonacci_generator(limit):
    a, b = 0, 1
    while a < limit:
        yield a
        a, b = b, a + b

# Uses almost no memory regardless of limit
for num in fibonacci_generator(1_000_000):
    if num > 100:
        break
    print(num, end=" ")
# 0 1 1 2 3 5 8 13 21 34 55 89

# Generator expression (like a list comprehension but lazy)
squares = (x**2 for x in range(1_000_000))
print(next(squares))  # 0
print(next(squares))  # 1

A generator maintains its state between yield calls, making it ideal for processing streams of data, reading large files line by line, and implementing custom iterators.

44. Explain context managers and the with statement.

Context managers guarantee that setup and cleanup code runs, even if an error occurs. They implement the __enter__ and __exit__ dunder methods.

# Using a context manager
with open("data.txt", "w") as f:
    f.write("Hello, World!")
# File is automatically closed, even if an exception occurs

# Creating a custom context manager with a class
class DatabaseConnection:
    def __enter__(self):
        self.conn = create_connection()
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.conn.close()
        return False  # Do not suppress exceptions

# Creating a context manager with contextlib
from contextlib import contextmanager

@contextmanager
def timer(label):
    import time
    start = time.time()
    yield
    print(f"{label}: {time.time() - start:.4f}s")

with timer("Data processing"):
    result = sum(range(10_000_000))

45. What is the Global Interpreter Lock (GIL)?

The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time. This means that CPU-bound Python code cannot achieve true parallelism with threads.

import threading
import multiprocessing
import time

def cpu_bound_task(n):
    return sum(i * i for i in range(n))

# Threading: no speedup for CPU-bound work due to GIL
# Use multiprocessing instead for CPU-bound parallelism

# multiprocessing bypasses the GIL
if __name__ == "__main__":
    with multiprocessing.Pool(4) as pool:
        results = pool.map(cpu_bound_task, [10_000_000] * 4)

Key points about the GIL:

  1. It only affects CPython (the standard Python implementation).
  2. I/O-bound tasks (network requests, file reads) still benefit from threading because the GIL is released during I/O operations.
  3. Use multiprocessing or asyncio to work around the GIL for CPU-bound work.
  4. Python 3.13 introduced an experimental free-threaded mode that removes the GIL.

46. What are metaclasses?

A metaclass is a class whose instances are classes. Just as a class defines how instances behave, a metaclass defines how classes behave.

class ValidatedMeta(type):
    def __new__(mcs, name, bases, namespace):
        # Enforce that all classes using this metaclass have a 'validate' method
        if "validate" not in namespace and name != "ValidatedBase":
            raise TypeError(f"{name} must implement a validate() method")
        return super().__new__(mcs, name, bases, namespace)

class ValidatedBase(metaclass=ValidatedMeta):
    pass

class User(ValidatedBase):
    def validate(self):
        return bool(self.name)

# class BadModel(ValidatedBase):  # TypeError: BadModel must implement validate()
#     pass

Metaclasses are rarely needed in application code but are used extensively in frameworks like Django (model definitions) and SQLAlchemy (declarative base).

47. How does asyncio work?

asyncio enables concurrent execution of I/O-bound tasks using a single thread with an event loop.

import asyncio

async def fetch_url(url):
    print(f"Fetching {url}...")
    await asyncio.sleep(1)  # Simulates network I/O
    return f"Data from {url}"

async def main():
    # Run three requests concurrently (not sequentially)
    tasks = [
        fetch_url("https://api.example.com/users"),
        fetch_url("https://api.example.com/orders"),
        fetch_url("https://api.example.com/products"),
    ]
    results = await asyncio.gather(*tasks)
    for result in results:
        print(result)

asyncio.run(main())
# All three complete in ~1 second, not ~3 seconds

48. What are type hints and why use them?

Type hints annotate the expected types of function parameters and return values. They do not affect runtime behavior but enable static analysis tools to catch bugs.

from typing import Optional

def calculate_discount(
    price: float,
    discount_percent: float,
    minimum: Optional[float] = None
) -> float:
    discounted = price * (1 - discount_percent / 100)
    if minimum is not None:
        return max(discounted, minimum)
    return discounted

# Modern Python (3.10+) uses union syntax
def process(value: int | str) -> str:
    return str(value).upper()

Use mypy or pyright to check type annotations: mypy myproject/.

49. What are slots and when should you use them?

__slots__ restricts the attributes an instance can have, reducing memory usage and slightly increasing attribute access speed.

class PointWithSlots:
    __slots__ = ("x", "y")

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

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

import sys
a = PointWithSlots(1, 2)
b = PointWithoutSlots(1, 2)

print(sys.getsizeof(a))  # ~56 bytes
print(sys.getsizeof(b))  # ~56 bytes (but b also has __dict__ overhead)

# a.z = 3  # AttributeError: 'PointWithSlots' has no attribute 'z'
b.z = 3    # Works fine

Use slots when you have many instances of a class (millions of objects) and memory matters.

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

Dataclasses automatically generate boilerplate methods like __init__, __repr__, __eq__, and more.

from dataclasses import dataclass, field

@dataclass
class Employee:
    name: str
    department: str
    salary: float
    skills: list = field(default_factory=list)

    @property
    def is_senior(self) -> bool:
        return self.salary > 100_000

# Auto-generated __init__, __repr__, __eq__
emp1 = Employee("Alice", "Engineering", 120_000, ["Python", "SQL"])
emp2 = Employee("Alice", "Engineering", 120_000, ["Python", "SQL"])

print(emp1)           # Employee(name='Alice', department='Engineering', ...)
print(emp1 == emp2)   # True
print(emp1.is_senior) # True

# Frozen (immutable) dataclass
@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

Use dataclasses for classes that are primarily containers of data. Use regular classes when you need complex initialization logic or metaclass features.

Preparation Strategy

Knowing the answers is only half the battle. Interviewers also evaluate how you communicate your thought process. For every question, practice explaining your reasoning out loud, not just writing the code. Describe the tradeoffs, mention the edge cases, and explain why you chose a particular approach.

  1. Practice writing code by hand. Many interviews use shared editors without autocomplete.
  2. Understand time and space complexity for every solution you write.
  3. Prepare questions to ask the interviewer. This shows engagement and helps you evaluate the company.
  4. Review the job description and focus on the categories most relevant to the role.
  5. Build projects that demonstrate the skills covered in these questions.

Master Python with our free Python for Business Beginners textbook.