Interview prep · Junior → Senior
Python Interview Questions
The questions a Python interviewer actually asks about the language itself — the data model, data structures, decorators, generators, OOP, the GIL and concurrency, async, typing, exceptions, and the classic gotchas — each answered with a short example and a link to the source. I keep these to the language and its core runtime, not framework trivia or whiteboard puzzles. Pair it with the Python version reference and the open Python roles on the jobs board.
Difficulty
Showing 0 of 0 questions
No questions match that combination. .
1 · Core language & data model
What is the difference between is and ==?
Junior
== tests equality of value (it calls __eq__); is tests identity — whether two names point to the very same object in memory (the same id()). Two distinct lists with equal contents are == but not is. The one place you reach for is is comparing against singletons: x is None, is True, is False.
a = [1, 2, 3] b = [1, 2, 3] a == b # True — same contents a is b # False — different objects a is a # True
Why it's asked / follow-up: it separates people who memorized syntax from people who understand Python's object model. Common follow-up: “why does 256 is 256 but 1000 is 1000 can be False?” — small-integer caching (see the gotchas section).
Source: docs.python.org — Comparisons.
Which built-in types are mutable, and which are immutable? Junior
Immutable: int, float, bool, str, bytes, tuple, frozenset, and None. Mutable: list, dict, set, bytearray, and most custom objects. Immutable doesn't mean constant — rebinding the name is fine; what you can't do is change the object in place. Only immutable (hashable) objects can be dict keys or set members.
s = "hello" s[0] = "H" # TypeError: str does not support item assignment s = "Hello" # fine — this rebinds the name, not the object t = (1, [2, 3]) t[1].append(4) # tuple is immutable, but the list inside it is not
Why it's asked / follow-up: mutability drives the default-argument trap, dict-key rules, and copy semantics. Follow-up: “is a tuple always immutable?” — the tuple itself is, but it can hold mutable objects.
Source: docs.python.org — Data model.
Does Python pass arguments by value or by reference? Mid
Neither, exactly — Python passes object references by value (often called “pass by assignment”). The function receives a new name bound to the same object. Rebinding the parameter inside the function doesn't affect the caller, but mutating a mutable object does, because both names point at the same object.
def rebind(x): x = x + [99] # local rebind — caller unaffected def mutate(x): x.append(99) # in-place — caller sees it nums = [1, 2] rebind(nums) # nums is still [1, 2] mutate(nums) # nums is now [1, 2, 99]
Why it's asked / follow-up: it's the root cause of a whole class of “why did my list change?” bugs. Follow-up: “how do you defend against mutating a caller's list?” — copy it (list(x) / x.copy()) before mutating.
What is “truthiness” — what counts as falsy? Junior
Any object can be tested for truth. Falsy values are: None, False, numeric zero (0, 0.0, 0j), and empty containers/sequences ("", [], {}, (), set()). Everything else is truthy. A class controls its own truthiness with __bool__ (or __len__). Prefer if items: over if len(items) > 0:.
if not items: # idiomatic empty check return # but beware: 0 and "" are also falsy — # use `is None` when None is the thing you mean to test if value is None:
Why it's asked / follow-up: the trap is conflating “empty/zero” with “missing.” Follow-up: “what's wrong with if not count: when 0 is a valid count?” — it treats a legitimate 0 as absent.
What is the difference between a shallow copy and a deep copy? Mid
A shallow copy (list(x), x.copy(), copy.copy) makes a new outer container but shares the same nested objects. A deep copy (copy.deepcopy) recursively copies everything, so the result shares nothing with the original.
import copy a = [[1, 2], [3, 4]] b = a.copy() # shallow b[0].append(99) # a[0] also changes — inner lists are shared c = copy.deepcopy(a) # fully independent
Why it's asked / follow-up: shallow-copy aliasing is a frequent source of subtle bugs. Follow-up: “when is a shallow copy enough?” — when the elements are immutable or you never mutate them in place.
Source: docs.python.org — copy.
2 · Data structures & complexity
When would you use a tuple instead of a list? Junior
Use a list for an ordered, homogeneous, growing collection you'll mutate. Use a tuple for a fixed, often heterogeneous record whose shape won't change — coordinates, a row, a function returning multiple values. Because tuples are immutable and hashable, they can be dict keys and set members; lists cannot.
point = (4, 5) # fixed record seen = {(0, 0), (4, 5)} # tuples as set members — fine seen = {[0, 0]} # TypeError: unhashable type: 'list'
Why it's asked / follow-up: it tests whether you pick types by intent, not habit. Follow-up: “is a tuple faster than a list?” — marginally, and it signals immutability, but that's rarely the deciding factor.
Source: docs.python.org — Data structures.
How does a dict work, and why are lookups O(1)? Mid
A dict is a hash table: each key's __hash__ is computed and reduced to a slot, so lookup, insert, and delete are amortized O(1) (worst case O(n) on pathological collisions). Keys must be hashable — which is why they must be immutable. Since Python 3.7 dicts also preserve insertion order as a language guarantee.
d = {"a": 1, "b": 2}
d["c"] = 3
list(d) # ['a', 'b', 'c'] — insertion order preserved (3.7+)
d.get("z", 0) # 0 — default avoids KeyError
Why it's asked / follow-up: dicts underpin objects, kwargs, and namespaces. Follow-up: “what makes a key valid?” — it must implement __hash__ and __eq__ consistently and be immutable.
Source: docs.python.org — Mapping types.
What's the time complexity of common operations on list, dict, and set? Mid
list: index and append are O(1); x in list, insert(0, ...), and pop(0) are O(n). dict / set: lookup, insert, delete, and membership are amortized O(1). So x in some_set is dramatically faster than x in some_list for large collections.
# Membership test: choose the structure for the access pattern big_list = list(range(1_000_000)) big_set = set(big_list) 999_999 in big_list # O(n) — scans 999_999 in big_set # O(1) — hashed from collections import deque q = deque() # O(1) appends/pops at both ends, unlike list
Why it's asked / follow-up: the classic real-world fix is swapping an in list check for an in set. Follow-up: “what if you need fast pops from the front?” — collections.deque.
Source: wiki.python.org — TimeComplexity.
What is a list comprehension, and when shouldn't you use one? Junior
A comprehension builds a list/dict/set in one expression, more readably and a bit faster than an equivalent for-loop with .append(). Don't use one when it has side effects, nests more than two levels, or grows unreadable — reach for a normal loop or a generator instead.
squares = [n*n for n in range(10) if n % 2 == 0] by_id = {u.id: u for u in users} # dict comprehension unique = {w.lower() for w in words} # set comprehension
Why it's asked / follow-up: it's the most common Python idiom, and overuse is a real code-review smell. Follow-up: “how do you avoid building the whole list in memory?” — use a generator expression (parentheses instead of brackets).
Which collections types do you reach for, and why?
Senior
defaultdict — auto-creates a default for missing keys (great for grouping); Counter — counts hashables and gives most_common(); deque — O(1) appends/pops at both ends; namedtuple — a lightweight immutable record with named fields; OrderedDict — mostly historical now that plain dicts keep order, but it still has move_to_end and order-sensitive equality.
from collections import defaultdict, Counter groups = defaultdict(list) for word in words: groups[len(word)].append(word) # no KeyError on first touch Counter(words).most_common(3) # top 3 by frequency
Why it's asked / follow-up: reaching for the right specialized type signals fluency. Follow-up: “defaultdict vs dict.setdefault?” — defaultdict wins in a loop; setdefault is fine for one-offs but evaluates its default eagerly.
Source: docs.python.org — collections.
3 · Functions, scope & decorators
What do *args and **kwargs do?
Junior
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The same */** syntax also unpacks a sequence or mapping back into arguments at a call site.
def log(level, *args, **kwargs): print(level, args, kwargs) log("INFO", 1, 2, user="ada") # INFO (1, 2) {'user': 'ada'} vals = [1, 2]; opts = {"user": "ada"} log("INFO", *vals, **opts) # unpacking at the call site
Why it's asked / follow-up: it shows up in every decorator and wrapper. Follow-up: “what does a bare * in a signature mean?” — everything after it is keyword-only.
Explain Python's scope rules (LEGB), and global vs nonlocal.
Mid
Name lookup walks Local → Enclosing → Global → Built-in. Assigning to a name makes it local unless you declare otherwise: global rebinds a module-level name, nonlocal rebinds a name in the nearest enclosing function.
def counter(): n = 0 def inc(): nonlocal n # without this, n += 1 raises UnboundLocalError n += 1 return n return inc
Why it's asked / follow-up: “UnboundLocalError on a variable I clearly set” is a classic confusion this explains. Follow-up: “why is mutating a global list fine without global?” — mutation isn't assignment; you only need global to rebind.
Source: docs.python.org — Naming and binding.
What is a closure? Mid
A closure is a nested function that remembers variables from the enclosing scope even after that scope has returned. Python implements this by keeping the referenced free variables alive as cells on the inner function. It's how decorators and simple factory functions carry state without a class.
def multiplier(factor): def mul(x): return x * factor # `factor` is captured return mul double = multiplier(2) double(10) # 20
Why it's asked / follow-up: closures are the mechanism behind decorators. Follow-up: “what gets captured — the value or the variable?” — the variable (by reference), which is the source of the late-binding loop gotcha.
Source: docs.python.org — Data model.
What is a decorator? Write one. Mid
A decorator is a callable that takes a function and returns a replacement, usually a wrapper that adds behavior around the original. @decorator above a def is just sugar for fn = decorator(fn). Always wrap the inner function with functools.wraps so the original's name, docstring, and signature survive.
import functools, time def timed(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): t = time.perf_counter() try: return fn(*args, **kwargs) finally: print(fn.__name__, time.perf_counter() - t) return wrapper
Why it's asked / follow-up: writing one live separates real understanding from cargo-culting @. Follow-ups: “why functools.wraps?” (preserve metadata) and “how do you write a decorator that takes arguments?” (add one more layer of nesting).
Source: docs.python.org — functools.wraps.
What does “functions are first-class objects” mean? Junior
Functions are ordinary objects: you can assign them to names, store them in lists/dicts, pass them as arguments, and return them. That's what makes callbacks, key= functions, and decorators possible. A lambda is just a small anonymous function expression.
words = ["banana", "fig", "cherry"] words.sort(key=len) # pass a function as the sort key ops = {"+": lambda a, b: a + b} # store functions in a dict ops["+"](2, 3) # 5
Why it's asked / follow-up: it underlies a lot of idiomatic Python. Follow-up: “when should you avoid lambda?” — when it gets long or needs a name; define a real function instead.
Source: docs.python.org — Lambdas.
4 · Generators, iterators & comprehensions
What is a generator, and how does yield work?
Mid
A generator is a function containing yield; calling it returns a lazy iterator. Each next() runs the body until the next yield, hands back that value, and suspends — preserving local state — until asked again. It computes values on demand, so it uses near-constant memory regardless of how many it produces.
def countdown(n): while n > 0: yield n # produce a value, then pause here n -= 1 for x in countdown(3): print(x) # 3, 2, 1 — one at a time, never all in memory
Why it's asked / follow-up: generators are the answer to “process a huge file without loading it all.” Follow-up: “generator vs list comprehension?” — a generator expression (x for x in ...) is lazy; a list comprehension is eager.
Source: docs.python.org — Yield expressions.
What's the difference between an iterable and an iterator? Mid
An iterable can produce an iterator via __iter__ (lists, dicts, strings). An iterator produces values via __next__ and raises StopIteration when exhausted; it's also iterable (its __iter__ returns itself). A for loop calls iter() on the iterable, then next() repeatedly.
nums = [1, 2, 3] # iterable it = iter(nums) # iterator next(it) # 1 next(it) # 2 # an iterator is consumed once — you can't rewind it
Why it's asked / follow-up: it explains why “my generator is empty the second time I loop it.” Follow-up: “is a list an iterator?” — no, it's an iterable; iter(list) gives a fresh iterator each time.
Source: docs.python.org — Glossary: iterator.
When does a generator save memory, and when is it the wrong choice? Senior
A generator wins when you stream a large or infinite sequence and consume it once — you never materialize the whole thing. It's the wrong choice when you need random access, len(), multiple passes, or to share the data across consumers; an exhausted generator yields nothing, and you'd just rebuild it. Then a list (or itertools.tee) is correct.
def read_lines(path): with open(path) as f: for line in f: # files are lazy iterators yield line.rstrip() # sum a 100GB file's line lengths in constant memory: total = sum(len(l) for l in read_lines(path))
Why it's asked / follow-up: it probes judgment, not syntax. Follow-up: “how do you iterate a generator twice?” — you can't; rebuild it, or capture to a list if it fits in memory.
Source: docs.python.org — Functional HOWTO.
What does itertools give you that a plain loop doesn't?
Senior
itertools provides memory-efficient, composable iterators: chain (flatten), islice (slice without materializing), groupby (group consecutive runs), product/permutations/combinations (combinatorics), count/cycle/repeat (infinite streams). They keep work lazy and avoid intermediate lists.
from itertools import islice, chain # first 5 of a potentially endless stream first5 = list(islice(stream(), 5)) # iterate several iterables as one, without concatenating for item in chain(a, b, c): ...
Why it's asked / follow-up: it signals fluency with lazy pipelines. Follow-up: “gotcha with groupby?” — it groups only consecutive equal keys, so you usually sort first.
Source: docs.python.org — itertools.
5 · OOP & the class model
What is self, and what are dunder methods?
Junior
self is the instance, passed automatically as the first parameter of an instance method — it's how a method reaches the object's own attributes. Dunder (“double underscore”) methods like __init__, __repr__, __eq__, __len__ hook into language syntax and built-ins, so your objects behave like native ones.
class Point: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): return f"Point({self.x}, {self.y})" repr(Point(1, 2)) # 'Point(1, 2)'
Why it's asked / follow-up: it confirms a working mental model of instances. Follow-up: “__repr__ vs __str__?” — __repr__ is for developers/debugging (unambiguous), __str__ is for end-user display; __repr__ is the fallback.
@classmethod vs @staticmethod vs @property — when do you use each?
Mid
@classmethod receives the class (cls) — ideal for alternative constructors. @staticmethod receives nothing implicit — a plain function namespaced on the class. @property exposes a method as a read-only-looking attribute, so you can compute or validate without changing the call site.
class Temp: def __init__(self, c): self.c = c @classmethod def from_f(cls, f): return cls((f - 32) / 1.8) # alt constructor @property def f(self): return self.c * 1.8 + 32 # Temp(20).f
Why it's asked / follow-up: the alternative-constructor pattern (cls(...)) is the give-away of someone who's built real classes. Follow-up: “why cls not the class name in a classmethod?” — so subclasses construct the right type.
Source: docs.python.org — classmethod / staticmethod / property.
How does multiple inheritance resolve — what is the MRO? Senior
The Method Resolution Order is the linear order Python searches base classes for an attribute. It's computed by the C3 linearization, which guarantees a consistent order that respects each class's local precedence and never visits a class before its subclasses. super() follows the MRO, not just “the parent,” which is what makes cooperative multiple inheritance work.
class A: ... class B(A): ... class C(A): ... class D(B, C): ... [c.__name__ for c in D.__mro__] # ['D', 'B', 'C', 'A', 'object']
Why it's asked / follow-up: the diamond problem is the canonical senior question. Follow-up: “what does super() actually do?” — it delegates to the next class in the instance's MRO, not necessarily the literal base.
Source: docs.python.org — The Python MRO.
What does @dataclass do for you?
Mid
@dataclass generates the boilerplate — __init__, __repr__, __eq__ (and ordering or immutability on request) — from class-level annotated fields. Use frozen=True for an immutable, hashable record, and field(default_factory=list) for mutable defaults (never a bare []).
from dataclasses import dataclass, field @dataclass(frozen=True) class User: name: str roles: list = field(default_factory=list) # safe mutable default
Why it's asked / follow-up: it's the modern default for plain data holders. Follow-up: “dataclass vs NamedTuple vs dict?” — dataclass for mutable typed records, NamedTuple for immutable tuple-like ones, dict for dynamic keys.
Source: docs.python.org — dataclasses.
What is __slots__, and what does it trade off?
Senior
By default each instance stores attributes in a per-instance __dict__. Declaring __slots__ replaces that dict with a fixed array of named fields, which cuts memory substantially and speeds attribute access — at the cost of not being able to add new attributes at runtime (and some extra care with inheritance and weak refs).
class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x, self.y = x, y p = Point(1, 2) p.z = 3 # AttributeError — no __dict__ to hold 'z'
Why it's asked / follow-up: it's a real optimization when you have millions of small objects. Follow-up: “when is it not worth it?” — for a handful of objects the memory win is negligible and you lose flexibility.
Source: docs.python.org — __slots__.
What is duck typing, and how do abstract base classes relate? Mid
Duck typing means code depends on an object's behavior (the methods it has), not its declared type — “if it walks like a duck.” abc.ABC lets you formalize a required interface and fail early if a subclass forgets a method; typing.Protocol does the same thing structurally for the type checker without inheritance.
from abc import ABC, abstractmethod class Storage(ABC): @abstractmethod def save(self, data): ... class S3(Storage): ... # TypeError at instantiation if save() is missing
Why it's asked / follow-up: it gets at Python's typing philosophy. Follow-up: “ABC vs Protocol?” — ABC is nominal (you inherit it); Protocol is structural (you just match the shape).
Source: docs.python.org — abc.
6 · Concurrency & the GIL
What is the GIL, and why does it exist? Senior
The Global Interpreter Lock is a mutex in CPython that lets only one thread execute Python bytecode at a time. It exists to make reference-counting memory management thread-safe and to keep the C implementation simple and fast for single-threaded code. The consequence: threads don't give you parallel speedup for CPU-bound Python work — only concurrency for I/O-bound work, where the GIL is released while waiting.
# Two CPU-bound threads do NOT run Python bytecode in parallel — # the GIL serializes them. Use processes (or a no-GIL build) for that.
Why it's asked / follow-up: it's the signature Python concurrency question. Follow-up: “is the GIL going away?” — the free-threaded build is the answer (next question).
Source: docs.python.org — Glossary: GIL.
Threading vs multiprocessing vs asyncio — when do you reach for each? Senior
Threading — I/O-bound work with blocking libraries; the GIL is released during I/O so threads overlap waits. Multiprocessing — CPU-bound work; each process has its own interpreter and GIL, achieving true parallelism at the cost of inter-process communication. asyncio — high-concurrency I/O on a single thread via an event loop and async libraries; thousands of in-flight tasks without thread overhead.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor # I/O-bound: many HTTP calls with ThreadPoolExecutor() as ex: ex.map(fetch, urls) # CPU-bound: heavy number-crunching with ProcessPoolExecutor() as ex: ex.map(crunch, chunks)
Why it's asked / follow-up: it tests whether you can match a concurrency model to a workload. Follow-up: “what's the cost of multiprocessing?” — process startup and pickling data across the boundary.
Source: docs.python.org — concurrent.futures.
Is the GIL going away? What is free-threaded Python? Senior
CPython now ships an optional free-threaded build that removes the GIL, letting threads run Python bytecode in parallel. It arrived as an experimental build in Python 3.13 (PEP 703) and became an officially supported build — though still not the default — in Python 3.14 (PEP 779). Standard builds still have the GIL, so don't assume it in production yet; the rollout is multi-phase.
# A free-threaded interpreter reports the GIL as disabled: import sys sys._is_gil_enabled() # False on a free-threaded (3.13t / 3.14t) build
Why it's asked / follow-up: it's the most current concurrency topic, and a great signal of someone who tracks the language. See the 3.13 and 3.14 rows on the versions page. Follow-up: “what breaks without the GIL?” — C extensions that assumed it, and code that leaned on the GIL as an implicit lock.
Does the GIL make threaded code automatically thread-safe? Senior
No. The GIL guarantees one bytecode executes at a time, but a high-level operation like counter += 1 compiles to several bytecodes (load, add, store), and a thread can be switched out between them — so increments can be lost. You still need a Lock (or an atomic structure like queue.Queue) around shared mutable state.
import threading lock = threading.Lock() def bump(): with lock: # make the read-modify-write atomic global n n += 1
Why it's asked / follow-up: it punctures the common “the GIL protects me” myth. Follow-up: “what's a lock-free alternative?” — hand work to a thread-safe queue.Queue instead of sharing state.
Source: docs.python.org — threading.
How does Python manage memory — reference counting and the GC? Senior
CPython's primary mechanism is reference counting: every object tracks how many references point to it, and is freed the instant that count hits zero. Because pure refcounting can't reclaim reference cycles (A ↔ B), a secondary generational garbage collector (the gc module) periodically finds and frees them.
import sys, gc a = [] sys.getrefcount(a) # shows the current count (incl. the temp ref) gc.collect() # force a cycle-collection pass
Why it's asked / follow-up: it explains both the GIL's existence and deterministic cleanup. Follow-up: “why can't refcounting free cycles?” — the objects keep each other's counts above zero.
7 · Async & asyncio
What do async and await actually do?
Mid
async def defines a coroutine; calling it returns a coroutine object that does nothing until awaited or scheduled. await suspends the current coroutine and yields control back to the event loop until the awaited thing is ready, letting other tasks run in the meantime — cooperative, single-threaded concurrency.
import asyncio async def fetch(n): await asyncio.sleep(1) # yields to the loop instead of blocking return n async def main(): return await asyncio.gather(*(fetch(i) for i in range(100))) asyncio.run(main()) # ~1s total, not 100s
Why it's asked / follow-up: async is everywhere in modern web/IO code. Follow-up: “what does asyncio.gather buy you over awaiting in a loop?” — it runs the awaitables concurrently rather than one after another.
Source: docs.python.org — asyncio tasks.
When is asyncio the wrong tool? Senior
asyncio shines for high-concurrency I/O with async-aware libraries. It's the wrong tool for CPU-bound work (a long computation blocks the whole loop — the GIL still applies), and it's painful when your dependencies are blocking/synchronous. A single blocking call inside a coroutine stalls every other task on that loop.
async def handler(): data = blocking_db_call() # BAD — freezes the event loop data = await asyncio.to_thread(blocking_db_call) # offload to a thread
Why it's asked / follow-up: it tests whether you understand the cooperative model's failure mode. Follow-up: “how do you run blocking code from async?” — asyncio.to_thread or a thread/process executor.
Source: docs.python.org — asyncio.
What's the difference between a coroutine and a Task? Mid
A coroutine object is inert — it runs only when awaited. A Task wraps a coroutine and schedules it on the loop immediately, so it makes progress concurrently while you do other things; you await it later for the result. asyncio.create_task (or a TaskGroup) is how you fan out concurrent work.
async def main(): async with asyncio.TaskGroup() as tg: # 3.11+ t1 = tg.create_task(fetch(1)) t2 = tg.create_task(fetch(2)) return t1.result(), t2.result()
Why it's asked / follow-up: “why didn't my coroutine run?” usually means it was never awaited or wrapped in a task. Follow-up: “what does TaskGroup add?” — structured concurrency with automatic cancellation on error.
Source: docs.python.org — asyncio Tasks.
What is the event loop? Senior
The event loop is the scheduler at the heart of asyncio. It maintains a queue of ready callbacks/tasks, runs one until it awaits (suspends), then picks the next ready one — using the OS's I/O readiness notifications (select/epoll/kqueue) to wake tasks when their I/O completes. asyncio.run() creates a loop, runs your coroutine to completion, and closes it.
asyncio.run(main()) # preferred entry point — owns the loop's lifecycle
Why it's asked / follow-up: it's the mental model that makes the rest of asyncio click. Follow-up: “can you have two loops in one thread?” — only one runs at a time per thread; nesting asyncio.run is an error.
Source: docs.python.org — Event loop.
8 · Typing & type hints
Do type hints change how Python runs? Junior
No — type hints are not enforced at runtime. CPython ignores them for execution; they exist for humans and for static checkers like mypy / pyright, plus editor autocomplete. You can still pass the “wrong” type and it'll run (and maybe crash later).
def greet(name: str) -> str: return "hi " + name greet(123) # runs, then TypeError at the +; mypy would flag it first
Why it's asked / follow-up: a surprising number of people think hints validate at runtime. Follow-up: “how would you enforce types at runtime?” — a validation library like pydantic, not the hints themselves.
Source: docs.python.org — typing.
What does Optional[X] mean, and how do you write unions now?
Mid
Optional[X] means “X or None” — it's exactly Union[X, None]. Since Python 3.10 the idiomatic spelling is the | operator: X | None. Note it does not mean “the argument is optional” — that's about having a default value, a separate concept.
def find(key: str) -> str | None: # 3.10+ syntax return cache.get(key) # pre-3.10 equivalent: Optional[str] / Union[str, None]
Why it's asked / follow-up: None-handling is where a lot of type errors live. Follow-up: “does Optional make the parameter optional?” — no; it only adds None to the allowed types.
Source: docs.python.org — typing.Optional.
How do generics work, and what changed in 3.12? Senior
Generics let a function or class be parameterized by a type variable so the checker preserves the relationship between input and output. Python 3.12 (PEP 695) added clean built-in syntax — def first[T](...) and class Stack[T] — replacing the older explicit TypeVar declarations.
def first[T](items: list[T]) -> T: # 3.12+ — no TypeVar needed return items[0] # pre-3.12: T = TypeVar("T"); def first(items: list[T]) -> T: ...
Why it's asked / follow-up: it shows you keep up with the language (see the 3.12 row). Follow-up: “what is variance?” — covariance/contravariance govern whether list[Cat] is acceptable where list[Animal] is expected (it isn't, for mutable containers).
Source: PEP 695.
What is a Protocol (structural typing)?
Senior
A typing.Protocol defines an interface by shape, not inheritance: any object with the right methods/attributes satisfies it, with no base class to inherit. It's static duck typing — the checker accepts a value because it matches, formalizing the duck-typing the language already does at runtime.
from typing import Protocol class Closeable(Protocol): def close(self) -> None: ... def shutdown(x: Closeable) -> None: # any object with .close() qualifies x.close()
Why it's asked / follow-up: it's the modern way to type duck-typed code. Follow-up: “Protocol vs ABC?” — Protocol is structural (no inheritance); ABC is nominal (you subclass it).
Source: docs.python.org — typing.Protocol.
9 · Exceptions & context managers
What is EAFP, and how does it differ from LBYL? Mid
EAFP — “easier to ask forgiveness than permission” — just attempt the operation and catch the exception if it fails. It's the Pythonic default and avoids time-of-check/time-of-use races. LBYL — “look before you leap” — checks preconditions first, which can be racy and redundant.
# EAFP (idiomatic) try: value = config["timeout"] except KeyError: value = 30 # LBYL: if "timeout" in config: ... (a race if config can change)
Why it's asked / follow-up: it captures a core Python philosophy. Follow-up: “is try/except slow?” — setting up the try is nearly free; only raising is costly, so EAFP is cheap when failures are rare.
Source: docs.python.org — Glossary: EAFP.
What do the else and finally clauses of try do?
Junior
else runs only if the try block raised no exception (keeping the “success” path out of the protected block). finally runs no matter what — success, exception, or even a return — so it's where cleanup goes.
try: conn = open_db() except ConnectionError: log("failed") else: use(conn) # only if no exception finally: cleanup() # always
Why it's asked / follow-up: many developers never use else, and explaining it shows depth. Follow-up: “what if both try and finally return?” — finally's return wins (and silently swallows exceptions — avoid it).
Source: docs.python.org — The try statement.
How does the with statement work? Write a context manager.
Mid
with guarantees setup and teardown around a block via the context-manager protocol: __enter__ runs on entry, __exit__ runs on exit — even if an exception is raised — which is why files and locks use it. The easiest way to write one is contextlib.contextmanager with a single yield.
from contextlib import contextmanager @contextmanager def timer(): t = time.perf_counter() try: yield # the body of the `with` runs here finally: print(time.perf_counter() - t)
Why it's asked / follow-up: it ties together exceptions, generators, and resource safety. Follow-up: “what does returning True from __exit__ do?” — it suppresses the exception.
Source: docs.python.org — contextlib.
How and why do you define custom exceptions? Junior
Subclass Exception (never BaseException) to create a domain-specific error callers can catch precisely without swallowing unrelated failures. Catch the narrowest exception you can handle; a bare except: also traps KeyboardInterrupt and SystemExit, which you almost never want.
class PaymentError(Exception): """Raised when a charge cannot be completed.""" try: charge(card) except PaymentError as e: # precise — not a blanket except notify(e)
Why it's asked / follow-up: blanket except blocks are a top code-review flag. Follow-up: “what's wrong with except Exception: pass?” — it hides bugs; at minimum log, and re-raise what you can't handle.
10 · Classic gotchas
Why is a mutable default argument dangerous? Mid
Default argument values are evaluated once, at function-definition time — not on each call. So a default [] or {} is a single shared object that accumulates across calls. The fix is the None sentinel.
def bad(item, bucket=[]): # shared across ALL calls bucket.append(item); return bucket bad(1); bad(2) # [1, 2] — surprise! def good(item, bucket=None): if bucket is None: bucket = [] bucket.append(item); return bucket
Why it's asked / follow-up: it's the most famous Python gotcha and a fast competence check. Follow-up: “why does Python do this?” — defaults are stored on the function object once, which is also what makes them cheap.
Why do all the closures in this loop print the same value? Senior
Closures capture the variable, not its value at capture time (“late binding”). All lambdas built in a loop share the same i, which by the time they're called holds its final value. Bind per-iteration with a default argument (or a factory).
fns = [lambda: i for i in range(3)] [f() for f in fns] # [2, 2, 2] — not [0, 1, 2] fns = [lambda i=i: i for i in range(3)] [f() for f in fns] # [0, 1, 2] — i bound per iteration
Why it's asked / follow-up: it tests a real understanding of closures and scope. Follow-up: “why does the default-argument trick work?” — the default is evaluated and frozen at each lambda's definition.
Source: docs.python.org — Programming FAQ.
Why does a is b work for small ints but not large ones?
Senior
CPython caches small integers from −5 to 256, so every reference to, say, 100 is the same cached object — making is accidentally pass. Outside that range, equal-valued ints can be distinct objects. This is a CPython implementation detail, not a language guarantee — which is exactly why you should compare value with ==, never is.
a = 256; b = 256 a is b # True — cached a = 257; b = 257 a is b # often False — distinct objects
Why it's asked / follow-up: it reinforces the is-vs-== rule with a concrete trap. Follow-up: “does the same happen for strings?” — yes, short/interned strings can share identity too; still use ==.
Source: docs.python.org — Integer objects.
Why does [[0]*3]*3 behave strangely?
Mid
The outer *3 copies the reference to the same inner list three times, so all three “rows” are the same object — mutating one mutates all. Build independent rows with a comprehension instead.
grid = [[0]*3]*3 grid[0][0] = 1 grid # [[1,0,0], [1,0,0], [1,0,0]] — all rows changed grid = [[0]*3 for _ in range(3)] # independent rows
Why it's asked / follow-up: it's a practical aliasing trap that bites people building matrices. Follow-up: “why is the inner [0]*3 fine?” — 0 is immutable, so sharing it doesn't matter.
Source: docs.python.org — Programming FAQ.
Every answer links its primary source inline — the Python documentation, the PEP archive, and the Python developer's guide. The questions are a curated set of the topics a Python interviewer commonly covers, not a copy of any question bank. Language-version details cross-link to the Python version reference. Last updated June 2026.
Mungomash LLC · More on Python