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

Junior — expected of any Python developer; fundamentals and syntax.
Mid — idioms, the data model, and the “why,” not just the “what.”
Senior — runtime internals, concurrency, performance, and trade-offs.
Difficulty

Showing 0 of 0 questions

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.

Source: docs.python.org — Objects, values and types.

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.

Source: docs.python.org — Truth value testing.

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).

Source: docs.python.org — List comprehensions.

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.

Source: docs.python.org — Arbitrary argument lists.

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.

Source: docs.python.org — Special method names.

@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.

Source: PEP 703, PEP 779.

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.

Source: devguide.python.org — Garbage collector.

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.

Source: docs.python.org — Errors and exceptions.

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.

Source: docs.python.org — Function definitions.

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

Last refreshed 2026-06-23 by Mimas — new page: Python interview questions.