Skip to main content
Python · lesson 34 of 34
Python intermediate Lesson 34 of 34

Python Interview Prep

30 Python interview questions with concise answers and runnable code: the GIL, mutable defaults, decorators, MRO, closures, copying and more.

By SkillByExample · Updated

Thirty questions that come up in Python technical screens, grouped by topic. Where an answer shows output, it is the real output of the snippet above it, run on Python 3.13.

Core Language

Q1: What is the GIL and how does it affect concurrency?

The Global Interpreter Lock is a mutex in CPython that allows only one thread to execute Python bytecode at a time. This means:

  • Threads don’t parallelize CPU-bound work — only one core is used
  • Threads do help I/O-bound work — the GIL is released during I/O, so threads can overlap waiting
  • multiprocessing bypasses the GIL by spawning separate processes
import sys, threading, time

def cpu_task():
    sum(i * i for i in range(3_000_000))

start = time.perf_counter()
cpu_task(); cpu_task()
sequential = time.perf_counter() - start

start = time.perf_counter()
threads = [threading.Thread(target=cpu_task) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
threaded = time.perf_counter() - start

print(f"GIL enabled: {sys._is_gil_enabled()}")
print(f"threaded / sequential: {threaded / sequential:.2f}")
$ python gil.py
GIL enabled: True
threaded / sequential: 1.01

Two threads took about as long as running the work twice in a row (the exact ratio varies run to run), because only one thread holds the GIL at a time. Use multiprocessing.Pool for real CPU parallelism. Python 3.13 also ships an experimental free-threaded build (PEP 703, run as python3.13t) where sys._is_gil_enabled() returns False and threads can use multiple cores; it is opt-in and not yet the default.

Q2: Explain mutable default arguments — what’s the trap?

Default argument values are evaluated once at function definition time, not on every call.

def append(item, lst=[]):          # bug: one list shared by every call
    lst.append(item)
    return lst

print(append(1))
print(append(2))                   # not [2]: the same list object again

def append_fixed(item, lst=None):  # fix: None as the sentinel
    if lst is None:
        lst = []
    lst.append(item)
    return lst

print(append_fixed(1))
print(append_fixed(2))
$ python mutable_default.py
[1]
[1, 2]
[1]
[2]

Q3: What is a decorator and how do you write one?

A decorator is a callable that takes a function and returns a replacement function. It adds behavior without modifying the original.

import functools, time

def timeit(func):
    @functools.wraps(func)  # preserves __name__, __doc__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.1f}s")
        return result
    return wrapper

@timeit
def slow():
    time.sleep(0.1)

slow()
print(slow.__name__)   # still "slow", thanks to functools.wraps
$ python timeit.py
slow took 0.1s
slow

Q4: What’s the difference between @staticmethod and @classmethod?

class MyClass:
    count = 0

    @staticmethod
    def utility(x):
        # No access to class or instance — like a regular function in the class namespace
        return x * 2

    @classmethod
    def create(cls):
        # cls is the class itself — works correctly with subclasses
        obj = cls()
        cls.count += 1
        return obj

@classmethod is used for alternative constructors. @staticmethod for namespace organization.

Q5: How does Python’s memory management work?

  1. Reference counting — every object has a reference count. Reaches zero → immediately freed.
  2. Cyclic GC — handles reference cycles (A → B → A) that ref counting can’t break.
  3. Memory pools — CPython pre-allocates pools for small objects to avoid fragmentation.
import sys

x = [1, 2, 3]
print(sys.getrefcount(x))   # x, plus the temporary reference from the call itself
y = x
print(sys.getrefcount(x))   # y is another reference to the same list
del y
print(sys.getrefcount(x))
$ python refcount.py
2
3
2

Data Structures and Algorithms

Q6: What are Python’s built-in data structures and their complexities?

StructureAccessSearchInsertDelete
listO(1)O(n)O(1) amortized (end)O(n)
dictO(1) avgO(1) avgO(1) avgO(1) avg
set—O(1) avgO(1) avgO(1) avg
dequeO(n)O(n)O(1) both endsO(1) both ends

Q7: How do you reverse a list in Python? Name all the ways.

lst = [3, 1, 4, 2]

print(lst[::-1])                  # new reversed list (slicing)
print(list(reversed(lst)))        # new reversed list (iterator)
print(sorted(lst, reverse=True))  # sorted descending, which is not the same thing
print(lst.reverse())              # in place, returns None
print(lst)
$ python reverse.py
[2, 4, 1, 3]
[2, 4, 1, 3]
[4, 3, 2, 1]
None
[2, 4, 1, 3]

Q8: How do you find duplicates in a list?

from collections import Counter

def find_duplicates(lst):
    return [item for item, count in Counter(lst).items() if count > 1]

print(find_duplicates([1, 2, 2, 3, 3, 3, 4]))
$ python duplicates.py
[2, 3]

Q9: What is a generator and when would you use one?

A generator function uses yield to produce values lazily. Use when the dataset is large or you only need to iterate once.

def read_large_file(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

# Processes line by line — constant memory regardless of file size
for line in read_large_file("10gb_file.log"):
    process(line)

Q10: How does list.sort() differ from sorted()?

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

lst = [3, 1, 4, 1, 5]
print(sorted(lst), lst)   # new list; lst unchanged
print(lst.sort())         # sorts in place, returns None
print(lst)
print(sorted("hello"))    # works on any iterable
$ python sort_vs_sorted.py
[1, 1, 3, 4, 5] [3, 1, 4, 1, 5]
None
[1, 1, 3, 4, 5]
['e', 'h', 'l', 'l', 'o']

OOP and Design

Q11: What is duck typing?

Python doesn’t check types — it checks behavior. If an object has the right methods, it works.

def process(stream):
    for line in stream:   # works for file, list, generator, StringIO...
        print(line.strip())

process(open("file.txt"))
process(["line 1\n", "line 2\n"])
process(line + "\n" for line in ["a", "b"])

Q12: What are dunder (magic) methods?

Double-underscore methods customize Python’s built-in operations:

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

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

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

    def __len__(self):              # len(v)
        return 2

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

Q13: What is multiple inheritance and the MRO?

Python supports multiple inheritance. The Method Resolution Order (MRO) determines which class’s method is called using the C3 linearization algorithm.

class A:
    def hello(self): return "A"

class B(A):
    def hello(self): return "B"

class C(A):
    def hello(self): return "C"

class D(B, C):
    pass

print(D().hello())
print([cls.__name__ for cls in D.__mro__])
$ python mro.py
B
['D', 'B', 'C', 'A', 'object']

Q14: What is super() and when do you use it?

super() delegates method calls to the next class in the MRO. Always use it instead of naming the parent class directly.

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)   # calls Animal.__init__
        self.breed = breed

Functional Python

Q15: What is map(), filter(), and when should you use them?

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

print(list(map(lambda x: x**2, numbers)))
print(list(filter(lambda x: x % 2 == 0, numbers)))

# Prefer comprehensions for readability:
print([x**2 for x in numbers])
print([x for x in numbers if x % 2 == 0])
$ python map_filter.py
[1, 4, 9, 16, 25]
[2, 4]
[1, 4, 9, 16, 25]
[2, 4]

Use map/filter with named functions when the function already exists.

Q16: What is functools.partial?

Creates a new function with some arguments pre-filled:

from functools import partial

def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube = partial(power, exp=3)

print(square(5), cube(3))
$ python partial.py
25 27

Q17: Explain closures.

A closure is a function that captures variables from its enclosing scope:

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # factor is captured from the outer scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))
print(double.__closure__[0].cell_contents)
$ python closure.py
10 15
2

Concurrency and I/O

Q18: When would you use asyncio vs threading vs multiprocessing?

  • asyncio — I/O-bound, async-native code (web APIs, database, sockets). Thousands of concurrent connections in one thread.
  • threading — I/O-bound, blocking libraries. GIL limits true CPU parallelism.
  • multiprocessing — CPU-bound work. Each process has its own GIL and memory.

Q19: What is the difference between @property and a regular attribute?

@property makes a method behave like an attribute, adding computed logic and validation:

import math

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

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        return math.pi * self._radius ** 2

c = Circle(5)
print(round(c.area, 2))     # computed on access, not stored
try:
    c.radius = -1
except ValueError as e:
    print(f"ValueError: {e}")
$ python property.py
78.54
ValueError: Radius cannot be negative

Advanced Topics

Q20: What are __slots__ and when do they help?

__slots__ restricts instance attributes to a fixed set, eliminating the per-instance __dict__, which saves memory when you create very many instances.

import tracemalloc

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

class SlotPoint:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x; self.y = y

def measure(cls, n=100_000):
    tracemalloc.start()
    points = [cls(i, i) for i in range(n)]
    size, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return size // n

plain, slotted = measure(Point), measure(SlotPoint)
print(f"Point:     {plain} bytes per instance")
print(f"SlotPoint: {slotted} bytes per instance")
print(f"saved:     {1 - slotted / plain:.0%}")
print(hasattr(SlotPoint(1, 2), "__dict__"))
$ python slots.py
Point:     127 bytes per instance
SlotPoint: 87 bytes per instance
saved:     31%
False

The figures include the list slot and the int stored in each instance, so the saving on the object itself is larger than 31%; how much you save depends on how many attributes the class has and what they hold. The trade-off: a slotted instance has no __dict__, so you cannot add attributes that are not listed.

Q21: What is a context manager and how do you create one?

from contextlib import contextmanager

@contextmanager
def managed_resource():
    resource = acquire()
    try:
        yield resource
    finally:
        release(resource)

with managed_resource() as r:
    use(r)

Q22: How does @lru_cache work?

from functools import lru_cache

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

It caches results in a dict keyed by arguments. The “LRU” (Least Recently Used) eviction policy keeps the most recently used results up to maxsize.

Q23: What is the difference between is and ==?

== tests value equality (calls __eq__). is tests identity (same object in memory).

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b, a is b)

x = None
print(x is None)   # the idiomatic None check
$ python is_vs_eq.py
True False
True

Q24: How do you handle circular imports?

Move the import inside the function that uses it, or restructure to extract shared code into a third module.

# Circular: a.py imports b.py, b.py imports a.py
# Fix: import inside the function
def get_user():
    from myapp.models import User   # deferred import
    return User.find(1)

Q25: What is *args and **kwargs?

def variadic(*args, **kwargs):
    print(args)     # tuple of positional arguments
    print(kwargs)   # dict of keyword arguments

variadic(1, 2, 3, name="Alice", age=30)

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

args = (1, 2, 3)
print(add(*args))   # unpack a tuple into positional arguments
$ python args_kwargs.py
(1, 2, 3)
{'name': 'Alice', 'age': 30}
6

Q26: What is a descriptor?

An object that defines __get__, __set__, or __delete__ to customize attribute access. property, staticmethod, and classmethod are all descriptors.

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

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

    def __set__(self, obj, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{self.name} must be a non-negative int")
        obj.__dict__[self.name] = value

class Order:
    quantity = Validated()
    price = Validated()

Q27: How do you flatten a nested list?

from itertools import chain

def flatten(lst):                   # recursive generator, any depth
    for item in lst:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

print(list(flatten([1, [2, [3, 4]], [5]])))
print(list(chain.from_iterable([[1, 2], [3, 4]])))   # one level only
$ python flatten.py
[1, 2, 3, 4, 5]
[1, 2, 3, 4]

Q28: What is the difference between shallow and deep copy?

import copy

original = [[1, 2], [3, 4]]
shallow = original.copy()        # or original[:]
deep = copy.deepcopy(original)

original[0].append(99)
print(shallow[0])   # inner list is shared
print(deep[0])      # completely independent
$ python copies.py
[1, 2, 99]
[1, 2]

Q29: How do you make a class hashable?

Implement both __hash__ and __eq__, and make equal objects hash equal. If you define __eq__ without __hash__, Python sets __hash__ = None, making the class unhashable.

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

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

    def __hash__(self):
        return hash((self.x, self.y))

class NoHash:
    def __eq__(self, other):
        return True

print(len({Point(1, 2), Point(1, 2)}))   # equal points collapse into one
print({Point(1, 2): "p"}[Point(1, 2)])
print(NoHash.__hash__)                   # __eq__ alone sets __hash__ to None
$ python hashable.py
1
p
None

Q30: What is typing.Protocol and how is it different from ABC?

Protocol enables structural subtyping — an object matches a Protocol if it has the right attributes, regardless of inheritance.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:                    # never inherits from Drawable
    def draw(self) -> None:
        print("Drawing circle")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())
print(isinstance(Circle(), Drawable))
$ python protocol.py
Drawing circle
True

Use Protocol for interfaces that third-party types should satisfy. Use ABC when you own the hierarchy and want to enforce implementation.

Frequently Asked Questions

What level are these questions aimed at?
The questions cover beginner through advanced topics. Junior roles focus on Q1-15, mid-level on Q1-22, senior/staff on all 30.
Should I memorize these answers?
No. Understand the concepts. Interviewers probe with follow-ups — if you memorized an answer without understanding it, you'll stall immediately.
What's the most commonly asked Python interview topic?
The GIL and concurrency, mutable default arguments, generators vs lists, and list/dict/set complexity come up most often in technical screens.