The 65 Python questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
65 questions with answersKey Takeaways
Python is a high-level, interpreted programming language created by Guido van Rossum and first released in 1991, prized for code that reads close to plain English. It's dynamically typed, supports procedural, object-oriented, and functional styles, and ships with a large standard library, which is why beginners get productive in days while it also powers production systems at scale. In interviews, Python questions probe understanding of the object model (mutability, identity, references) and the runtime (the GIL, memory management), not memorized trivia. This page collects the 65 questions that come up most, each with a direct answer and runnable code. If you're still building fundamentals, the official Python tutorial from the Python Software Foundation is the canonical path; increasingly the first Python round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Python Full Course for Beginners
Video: Python Full Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Python certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Python is a high-level, interpreted programming language known for readable syntax and a huge standard library. It's dynamically typed, supports procedural, object-oriented, and functional styles, and runs on every major platform.
Its popularity comes from low friction: code reads close to English, the ecosystem covers everything from web development (Django, Flask) to data science (pandas, NumPy) and machine learning (PyTorch, scikit-learn), and beginners become productive in days rather than months.
Key point: A one-line definition plus two concrete ecosystem examples beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
A list is mutable: you can add, remove, and change elements after creation. A tuple is immutable: once created, its contents can't change. Lists use square brackets, tuples use parentheses.
Because tuples are immutable they're hashable (usable as dictionary keys), slightly faster, and signal intent: this collection is fixed. Lists are for sequences that grow and change.
nums_list = [1, 2, 3]
nums_list.append(4) # fine
nums_tuple = (1, 2, 3)
nums_tuple[0] = 9 # TypeError: does not support item assignment
locations = {(35.6, 139.6): "Tokyo"} # tuple as dict key works| List | Tuple | |
|---|---|---|
| Mutability | Mutable | Immutable |
| Syntax | [1, 2, 3] | (1, 2, 3) |
| Dict key / set member | No (unhashable) | Yes, if elements are hashable |
| Typical use | Changing sequences | Fixed records, keys |
Key point: The follow-up is almost always 'when would you choose a tuple?'. Have the dictionary-key example ready.
Mutable: list, dict, set, bytearray, and most user-defined objects. Immutable: int, float, bool, str, tuple, frozenset, and bytes.
This distinction drives real behavior: immutable objects can be dict keys, are safe to share between functions, and explain why string concatenation in a loop creates a new object every iteration. Mutability is also the root of Python's most famous gotcha, the mutable default argument.
Key point: Interviewers use this to set up the default-argument and aliasing questions. Answer it crisply and expect a follow-up.
Default argument values are evaluated once, at function definition, not on each call. A mutable default like a list is shared across every call that doesn't pass the argument, so mutations accumulate.
The fix is the None-sentinel pattern: default to None and create the list inside the function.
def broken(item, bucket=[]):
bucket.append(item)
return bucket
broken(1) # [1]
broken(2) # [1, 2] <- surprise, same list
def fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucketKey point: Explaining WHY (definition-time evaluation) is what separates a memorized answer from understanding.
A dict maps hashable keys to values with average O(1) lookup, insertion, and deletion. Internally it's a hash table: the key's hash picks a slot, and collisions are resolved by open addressing.
Since Python 3.7, dicts preserve insertion order as a language guarantee. Keys must be hashable, which is why lists can't be keys but tuples can.
user = {"name": "Asha", "role": "engineer"}
user["team"] = "platform" # O(1) insert
print(user.get("salary", 0)) # 0, no KeyErrorA shallow copy creates a new outer object but shares the inner objects: copying a list of lists gives you a new list whose elements are the same inner lists. A deep copy recursively copies everything, producing fully independent objects.
Use copy.copy() for shallow and copy.deepcopy() for deep. The classic bug is mutating a nested structure through what you thought was an independent copy.
import copy
grid = [[1, 2], [3, 4]]
shallow = copy.copy(grid)
shallow[0][0] = 99
print(grid[0][0]) # 99 <- shared inner list
deep = copy.deepcopy(grid)
deep[1][0] = 77
print(grid[1][0]) # 3 <- independent== compares values; is compares identity, whether two names point to the same object in memory. Two equal lists satisfy == but not is.
The practical rule: use == for comparisons, and reserve is for None (if x is None), because None is a singleton.
a = [1, 2]
b = [1, 2]
a == b # True (same value)
a is b # False (different objects)
x = None
x is None # the idiomatic None checkKey point: small-integer caching (Python interns small ints, so 5 is 5 happens to be True) matters.
Functions can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures, exactly like any other object.
This is the foundation for decorators, callbacks, and functional tools like map and sorted's key argument. If you understand first-class functions, decorators stop being magic.
def shout(text):
return text.upper()
def whisper(text):
return text.lower()
def greet(styler):
return styler("Hello there")
greet(shout) # "HELLO THERE"
greet(whisper) # "hello there"Watch a deeper explanation
Video: First-Class Functions in Python (Corey Schafer, YouTube)
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. They let a function accept a flexible number of arguments.
The names args and kwargs are convention; the stars are the syntax. The same stars also unpack in the other direction when calling a function.
def report(*args, **kwargs):
print(args) # (1, 2)
print(kwargs) # {"unit": "ms"}
report(1, 2, unit="ms")
point = (3, 5)
print(*point) # unpacking: print(3, 5)A list comprehension builds a list from an iterable in one readable expression: [expr for item in iterable if condition]. It replaces a three-line append loop with one line and is usually faster.
Use them for simple transform-and-filter operations. When the logic needs multiple conditions or nested loops beyond one level, a regular loop reads better, and reviewers notice when a comprehension has been stretched past readability.
squares_of_evens = [n * n for n in range(10) if n % 2 == 0]
# [0, 4, 16, 36, 64]
# dict and set comprehensions use the same shape
lengths = {word: len(word) for word in ["hire", "me"]}Watch a deeper explanation
Video: Python Comprehensions: How They Work and Why You Should Use Them (Corey Schafer, YouTube)
range() returns a lazy sequence object that generates numbers on demand instead of storing them. range(1_000_000_000) uses a few dozen bytes, not gigabytes.
It supports indexing, slicing, len(), and membership tests without materializing values. Wrap it in list() only when you genuinely need the whole list.
String immutability makes strings hashable (usable as dict keys), safe to share without copying, and allows interning optimizations. Any operation that seems to modify a string actually creates a new one.
The practical implication: building a large string with += in a loop is O(n squared) because every step copies. Collect parts in a list and join once.
parts = []
for row in rows:
parts.append(render(row))
html = "".join(parts) # one allocation, the idiomatic wayBuilding a big string: += in a loop vs join
Operations to assemble a string of N pieces, N = 1000 (log scale). += copies the whole string every step.
Key point: The join idiom is the expected follow-up. Volunteering it answers the next question before it's asked.
PEP 8 is Python's official style guide: naming conventions, indentation (4 spaces), line length, import ordering, and spacing rules. It matters because Python code is read far more often than written, and a shared style makes every codebase navigable.
In practice teams enforce it with tools like flake8, black, or ruff rather than by memory. Saying that shows you've worked on real codebases.
Python is dynamically typed: variables get their type from the object they reference at runtime, and the same name can rebind to different types. It's also strongly typed: '1' + 1 raises rather than silently coercing.
Type hints (def total(prices: list[float]) -> float:) add optional annotations that tools like mypy check statically. The runtime ignores them; teams use them for correctness and editor support on larger codebases.
It runs a block only when the file is executed directly, not when it's imported. Python sets __name__ to "__main__" for the entry-point script and to the module's name on import.
This lets one file be both an importable module and a runnable script, which is why nearly every Python script ends with this guard.
def main():
print("running as a script")
if __name__ == "__main__":
main()try runs code that may fail; except catches named exception types; else runs when nothing was raised; finally always runs, for cleanup. Raise exceptions with raise, and prefer catching specific types over a bare except.
A bare except swallows everything, including KeyboardInterrupt and genuine bugs, which turns crashes into silent corruption. Specific exception types keep real failures visible.
try:
value = int(user_input)
except ValueError:
value = 0
else:
print("clean parse")
finally:
print("always runs")with runs a block inside a context manager that guarantees setup and cleanup: the classic case is opening a file that closes itself even if the block raises.
Anything with __enter__ and __exit__ works: locks, database transactions, temporary directories. It replaces fragile try/finally cleanup with one line.
with open("data.csv") as f:
rows = f.readlines()
# f is closed here, even if readlines() raisedA lambda is an anonymous single-expression function: lambda x: x * 2. It's appropriate as a short throwaway passed to sorted, max, min, or filter, where naming a function would add noise.
The moment logic needs a second expression, a name, or a docstring, use def. PEP 8 explicitly prefers def when you're assigning a lambda to a name.
people = [("Asha", 31), ("Ben", 26)]
people.sort(key=lambda p: p[1]) # sort by ageAn iterable is anything you can loop over (lists, strings, dicts): it produces an iterator via iter(). An iterator is the object that actually yields items one at a time via next() and remembers its position.
Every iterator is an iterable, but not vice versa. A list can be looped repeatedly; an iterator is exhausted after one pass, a distinction that explains many 'my loop ran empty' bugs.
nums = [1, 2, 3] # iterable
it = iter(nums) # iterator
next(it) # 1
next(it) # 2
list(it) # [3] <- only what's leftbreak exits the nearest loop entirely; continue skips to the loop's next iteration; pass does nothing and exists as a syntactic placeholder where a statement is required.
Bonus depth: loops can have an else clause that runs only when the loop completed without break, a genuinely obscure feature that interviewers occasionally probe.
in tests membership: 3 in [1, 2, 3] is True, "ell" in "hello" checks substrings, and key in dict checks keys, not values.
Performance matters here: membership in a list is O(n), in a set or dict it's O(1) average. Converting a lookup list to a set is one of the cheapest optimizations in Python.
Membership test cost as the collection grows
Drag to change the number of items. A list is scanned item by item; a set jumps straight to the answer.
At 1,000 items, the list can do up to 1,000 comparisons; the set does about one. Converting a lookup list to a set is one of the cheapest speedups in Python.
Slicing extracts a portion of a sequence with [start:stop:step]; start is inclusive, stop is exclusive, and all three parts are optional. Negative indices count from the end.
Slices never raise IndexError for out-of-range bounds, and [::-1] is the idiomatic reversal. Slicing a list returns a new (shallow) copy, which is also the classic quick-copy idiom.
text = "interview"
text[0:5] # "inter"
text[-4:] # "view"
text[::-1] # "weivretni"
nums = [1, 2, 3]
clone = nums[:] # shallow copyNone is Python's null: a singleton object representing absence of a value. Functions without an explicit return produce None.
Check with is None, not == None: identity is the intended semantic, is can't be overridden by a class's __eq__, and it's the PEP 8 rule. Also don't confuse None with falsy values like 0 or an empty list when both are possible inputs.
The lookup proceeds Local, Enclosing, Global, Built-in: the function's own scope first, then any enclosing function, then the module, then built-ins.
Assignment creates a local name by default, which is why reading a global works but assigning to it needs the global keyword, and modifying an enclosing function's variable needs nonlocal. This question is the gateway to closures.
def counter():
count = 0
def bump():
nonlocal count
count += 1
return count
return bump
next_id = counter()
next_id() # 1
next_id() # 2f-strings (formatted string literals, f"Hello {name}") embed expressions directly in strings. They're the preferred formatting style since Python 3.6: more readable than %-formatting or .format(), and faster because they're evaluated at compile time to bytecode.
They support any expression, format specifiers (f"{price:.2f}"), and since 3.8 the debugging form f"{value=}" that prints both the expression and its value.
For candidates with working experience: language mechanics, standard library judgment, and the questions that separate users from understanders.
A decorator is a function that takes a function and returns a modified or wrapped version of it. The @decorator syntax is shorthand for func = decorator(func) at definition time.
Decorators handle cross-cutting concerns (timing, caching, auth checks, logging) without touching the wrapped function's body. Use functools.wraps so the wrapper preserves the original's name and docstring.
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def slow_sum(n):
return sum(range(n))Key point: Being able to write one from memory, with functools.wraps, is the bar. The follow-up is usually 'what breaks without wraps?' (introspection and debugging).
Watch a deeper explanation
Video: Python Decorators: Dynamically Alter the Functionality of Your Functions (Corey Schafer, YouTube)
A generator is a function that yields values one at a time instead of returning once; each next() resumes execution right after the last yield, with all local state intact. It produces values lazily, so memory stays flat regardless of sequence length.
Use generators for large or infinite streams: reading huge files line by line, paginated APIs, pipelines. Generator expressions ((x*x for x in data)) are the comprehension-shaped version.
def read_large_file(path):
with open(path) as f:
for line in f:
yield line.strip()
# processes one line at a time, never loads the file
for line in read_large_file("huge.log"):
handle(line)Watch a deeper explanation
Video: Python Generators: How to Use Them and the Benefits You Receive (Corey Schafer, YouTube)
The GIL is a mutex in CPython that lets only one thread execute Python bytecode at a time. Threads still help for I/O-bound work (the GIL is released while waiting on network or disk), but CPU-bound threads can't run in parallel.
For CPU parallelism, use multiprocessing (separate processes, separate GILs) or push the work into C-backed libraries like NumPy that release the GIL internally. Recent CPython versions ship an experimental free-threaded build, worth as awareness, but the standard answer above is what production Python matters.
| Workload | Right tool | Why |
|---|---|---|
| I/O-bound (APIs, disk) | threading or asyncio | GIL released during waits |
| CPU-bound (math, parsing) | multiprocessing | Separate processes bypass the GIL |
| Numeric arrays | NumPy and friends | C code releases the GIL internally |
Key point: The trap is claiming threads are useless. Showing you know threads work fine for I/O is exactly what this question screens for.
Threading: I/O-bound work with a moderate number of tasks, or when you must call blocking libraries. Multiprocessing: CPU-bound work that needs real parallelism. Asyncio: I/O-bound work at high concurrency (thousands of connections) where cooperative single-threaded scheduling beats thread overhead, provided your libraries are async-aware.
The honest interview answer includes the cost side: multiprocessing pays serialization and memory overhead, and asyncio requires an async ecosystem top to bottom, since one blocking call stalls the whole event loop.
CPython uses reference counting as its primary mechanism: every object tracks how many references point to it, and when the count hits zero the object is freed immediately. A supplementary cyclic garbage collector handles reference cycles that counting alone can't free.
Objects live on a private heap managed by the interpreter; small objects go through the pymalloc allocator. You rarely manage memory manually, but the model explains __del__ timing, why cycles need the gc module, and why del only removes a name.
A closure is a function that captures variables from its enclosing scope and keeps them alive after the outer function returns. The inner function 'closes over' those names.
Closures power decorators, factory functions, and callbacks with baked-in state. The classic gotcha: closures capture variables, not values, so loop variables captured in lambdas all see the final value unless you bind them as default arguments.
# gotcha: all three print 2
funcs = [lambda: i for i in range(3)]
# fix: bind the current value as a default
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]Watch a deeper explanation
Video: Closures: How to Use Them and Why They Are Useful (Corey Schafer, YouTube)
Classes define attributes and methods; instances get their own __dict__ of attributes; methods receive the instance as self explicitly. Python supports inheritance (including multiple), polymorphism through duck typing, and encapsulation by convention (a leading underscore means internal, double underscore triggers name mangling).
The Pythonic emphasis is duck typing: code cares whether an object supports the needed behavior, not what class it is. That's why interfaces are informal and isinstance checks are used sparingly.
Watch a deeper explanation
Video: Python OOP Tutorial 1: Classes and Instances (Corey Schafer, YouTube)
Instance methods receive self and act on one object. Class methods receive cls and act on the class, most commonly as alternative constructors. Static methods receive neither; they're plain functions namespaced inside the class for organization.
class Invoice:
def __init__(self, total):
self.total = total
@classmethod
def from_line_items(cls, items): # alternative constructor
return cls(sum(items))
@staticmethod
def validate_currency(code): # utility, no self/cls
return len(code) == 3
inv = Invoice.from_line_items([120, 80])Key point: The from_* alternative-constructor pattern is the answer interviewers hope to hear for 'when would you use a classmethod?'.
Dunder methods (double underscore: __init__, __repr__, __eq__) let your objects plug into Python's syntax: operators, len(), iteration, string display, context managers. Implementing them is how a custom class behaves like a built-in.
The everyday set: __init__ (construction), __repr__ (debugging display, aim for unambiguous), __eq__ and __hash__ (equality and dict/set membership, always together), __len__ and __iter__ (container behavior), __enter__/__exit__ (with-statement support).
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __eq__(self, other):
return (isinstance(other, Money)
and (self.amount, self.currency) == (other.amount, other.currency))
def __hash__(self):
return hash((self.amount, self.currency))Python linearizes the class hierarchy with the C3 algorithm: each class's MRO is a single ordered list that respects every parent's order, child before parent, and each class appearing once. Method lookup walks that list; ClassName.__mro__ shows it.
super() follows the MRO, not simply 'the parent', which is what makes cooperative multiple inheritance work: every class in the chain calls super() and the MRO guarantees each __init__ runs exactly once.
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
D.__mro__ # D -> B -> C -> A -> objectAn abstract base class (via the abc module) defines methods subclasses must implement; instantiating a class with unimplemented abstract methods raises immediately. Use ABCs when you're designing a plugin-style interface and want loud, early failure instead of a missing-method crash at runtime.
The counterweight to mention: idiomatic Python often prefers duck typing and Protocols (structural typing from the typing module) over nominal ABCs. Knowing both options and the trade-off is the senior-sounding answer.
@property turns a method into an attribute-style accessor: callers write obj.total instead of obj.total(), while your code computes, validates, or caches behind the scenes. Paired @total.setter intercepts assignment.
Its real value is evolution: a plain attribute, and if validation is needed later, add a property without breaking a single caller comes first. That's the answer to 'why not just getters and setters like Java?'.
class Order:
def __init__(self):
self._items = []
@property
def total(self):
return sum(self._items)
order = Order()
order._items = [10, 15]
order.total # 25, no parenthesesSubclass Exception (never BaseException), name it with an Error suffix, and raise it where the failure happens. A small exception hierarchy per package (a base error plus specific subclasses) lets callers catch at whatever granularity they need.
Also worth saying: chain causes with raise NewError(...) from original so tracebacks keep the root cause.
class PaymentError(Exception): pass
class CardDeclinedError(PaymentError): pass
try:
charge(card)
except GatewayTimeout as e:
raise PaymentError("gateway unavailable") from eA module is a single .py file; a package is a directory of modules, traditionally marked by __init__.py, that creates a namespace hierarchy (package.module.function).
__init__.py runs on first import and commonly curates the package's public surface by re-exporting key names. Imports resolve via sys.path, which explains most 'ModuleNotFoundError' debugging.
A virtual environment is an isolated Python installation per project: its own interpreter link and site-packages, so project A's dependency versions can't collide with project B's. python -m venv .venv creates one; activation just adjusts PATH.
Production answers pin dependencies (requirements.txt or a lock file from a tool like poetry or uv) so environments are reproducible on every machine and in CI.
enumerate(iterable) yields (index, item) pairs, replacing manual counters. zip(a, b) pairs elements from multiple iterables, stopping at the shortest; zip(*pairs) inverts it, the idiomatic 'unzip'.
names = ["Asha", "Ben"]
scores = [91, 84]
for i, (name, score) in enumerate(zip(names, scores), start=1):
print(f"{i}. {name}: {score}")list.sort() sorts in place and returns None; sorted() works on any iterable and returns a new list. Both take key (a function computing each element's sort value) and reverse, and both are stable: equal elements keep their relative order.
Stability is the underrated half of this answer: sorting by secondary key then primary key produces a correct multi-level sort precisely because Timsort is stable.
rows = [("Asha", "eng", 31), ("Ben", "eng", 26), ("Cara", "ops", 29)]
rows.sort(key=lambda r: r[2]) # by age
rows.sort(key=lambda r: r[1]) # then by team: stable multi-level sortCounter (frequency counting with most_common), defaultdict (auto-initializing values, ends the key-check dance), deque (O(1) appends and pops at both ends, the right queue), and namedtuple or dataclasses for lightweight records.
Reaching for these instead of reimplementing them is a strong signal of real-world fluency; Counter alone collapses a dozen-line loop to one line.
from collections import Counter, defaultdict
Counter("mississippi").most_common(2) # [("i", 4), ("s", 4)]
groups = defaultdict(list)
for name, team in pairs:
groups[team].append(name) # no key check neededPython passes references to objects (call it pass-by-object-reference): the parameter and the caller's variable The same list, so in-place mutation is visible to both. Rebinding the parameter (param = [...]) only changes the local name.
The distinction to articulate is mutation versus rebinding. Functions that mutate arguments says so loudly; otherwise return a new object and leave inputs alone.
def add_item(bucket):
bucket.append(99) # mutates the caller's list
def replace(bucket):
bucket = [99] # rebinds local name only
items = [1]
add_item(items) # items == [1, 99]
replace(items) # items unchangedThe json module: json.dumps(obj) to a string, json.loads(s) back to Python objects, with dump/load for file handles. Dicts, lists, strings, numbers, booleans, and None map directly; anything else (datetimes, custom classes) needs a default function or explicit conversion.
Mention pickle only with its warning label: it executes arbitrary code on load, so it's for trusted internal data only, never user input.
pytest is the practical standard: test files named test_*.py, plain functions with assert statements, fixtures for setup, parametrize for input matrices, and one command to run everything. The standard library's unittest works everywhere but needs more ceremony.
The technical detail is testing behavior rather than implementation, and mocking external boundaries (network, clock, database) with unittest.mock or pytest fixtures.
# test_pricing.py
import pytest
from pricing import apply_discount
@pytest.mark.parametrize("price,pct,expected", [
(100, 10, 90.0),
(100, 0, 100.0),
])
def test_apply_discount(price, pct, expected):
assert apply_discount(price, pct) == expectedThe walrus operator assigns and returns a value inside an expression, letting you capture a result where a plain statement can't go: while chunk := f.read(8192): processes a stream without priming reads or repeated calls.
Use it where it removes duplication (loop conditions, comprehension filters that reuse a computed value); skip it where it just compresses two clear lines into one clever one.
advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
CPython compiles source to bytecode (the .pyc files cached in __pycache__), then a stack-based virtual machine interprets that bytecode. So Python is both compiled and interpreted; the compilation just targets the VM rather than the CPU.
Senior-level extras: the bytecode cache invalidates on source change, import runs a module's top level exactly once per process, and alternative runtimes (PyPy's JIT) change the execution story without changing the language.
A descriptor is any class implementing __get__, __set__, or __delete__; when an instance of it sits on a class, attribute access on instances routes through those methods. This is the machinery beneath properties, methods, classmethod, and staticmethod.
@property is a data descriptor built for you. Writing a custom descriptor earns its keep when the same attribute behavior (validation, units, lazy loading) must repeat across many attributes or classes.
class Positive:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__[self.name]
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
obj.__dict__[self.name] = value
class Product:
price = Positive()
stock = Positive() # same validation, zero duplicationA metaclass is the class of a class: type by default. It intercepts class creation itself, letting you validate, register, or rewrite classes as they're defined. ORMs and serialization frameworks use metaclasses to turn declarative class bodies into machinery.
The honest production-ready answer: almost never write one. __init_subclass__ and decorators cover most registration and validation cases with far less magic. Knowing the mechanism but reaching for simpler tools first is the signal the question needs.
An event loop runs coroutines cooperatively: await yields control back to the loop, which multiplexes thousands of paused coroutines over OS-level I/O readiness (epoll/kqueue). Nothing runs in parallel; everything interleaves at await points on one thread.
The two production rules that follow: any blocking call (requests, time.sleep, heavy CPU) freezes every coroutine, so blocking work goes to run_in_executor or async-native libraries; and structured concurrency (gather, TaskGroup) beats fire-and-forget tasks that swallow exceptions.
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main(urls):
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*(fetch(session, u) for u in urls))Key point: 'What happens if you call a blocking function inside a coroutine?' is the follow-up. The answer: the whole loop stalls; that's the point of this question.
Measure first: cProfile or py-spy for CPU hot spots, tracemalloc for memory, and a realistic workload, because intuition about bottlenecks is usually wrong. Then fix in order of impact: better algorithms and data structures, batching I/O, caching (functools.lru_cache), vectorizing with NumPy or pandas, and only then parallelism.
the check is for the discipline (profile, don't guess) and the escalation ladder ending at 'rewrite the hot loop in C/Rust or use a faster runtime' as the last resort, not the first.
Yes, practically: unbounded caches and module-level containers that only grow, reference cycles involving __del__, closures capturing large objects, and C-extension leaks all inflate long-running processes even with garbage collection.
Diagnosis: tracemalloc snapshots diffed over time to find the growing allocation sites, gc.get_objects()/objgraph for cycle hunting, and process-level RSS tracking to confirm. The fix is usually a bounded cache (lru_cache maxsize, TTL) or breaking the cycle with weakref.
dataclasses generate __init__, __repr__, and __eq__ from annotated fields: the default for structured records in modern code. namedtuple gives immutable, tuple-compatible records with lower memory. Pydantic adds runtime validation and parsing, the right tool at trust boundaries (APIs, config). attrs is the feature-richer ancestor of dataclasses, still common in libraries.
The judgment call interviewers probe: validation at boundaries (Pydantic), plain data inside the system (dataclasses), and frozen=True when immutability communicates intent.
| Tool | Validation | Mutability | Best at |
|---|---|---|---|
| dataclass | None (hints only) | Either (frozen flag) | Internal records |
| namedtuple | None | Immutable | Lightweight tuples with names |
| Pydantic | Runtime parsing | Configurable | API/config boundaries |
| attrs | Optional validators | Either | Library authors, rich features |
Incrementally: run mypy (or pyright) with permissive settings, type the boundaries first (public APIs, data models), enforce no-new-untyped-code in CI, and ratchet strictness module by module. Typed dicts, Protocols, and Optional discipline pay for themselves at boundaries fastest.
The failure mode to name: a big-bang typing sprint that produces thousands of errors and gets abandoned. Gradual typing is a migration, and the tooling (per-module overrides) exists precisely for that.
pyproject.toml is the single source of truth: project metadata, dependencies, and build backend. Build produces a wheel (prebuilt) and sdist; publishing goes to PyPI via twine or the build tool. Lock files (poetry, uv, pip-tools) pin the full dependency graph for reproducibility.
For applications, the deliverable is usually a container image with a locked environment rather than a published package; saying that distinction out loud indicates production experience.
Two ways: a class with __enter__ and __exit__ (where __exit__ receives exception info and can suppress by returning True), or the lighter @contextlib.contextmanager decorator around a generator that yields exactly once, with try/finally providing the cleanup.
from contextlib import contextmanager
import time
@contextmanager
def timed(label):
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timed("batch import"):
run_import()lru_cache memoizes a function keyed by its arguments, evicting least-recently-used entries past maxsize. It's the one-line fix for repeated pure computation.
The bites: arguments must be hashable; caching methods keeps instances alive (leak via self) unless you use cached_property or a per-instance cache; unbounded (maxsize=None) caches grow forever; and caching anything impure trades correctness for speed.
Implement __iter__ returning an iterator, and the easiest correct iterator is a generator: write __iter__ as a generator function and Python handles __next__ and StopIteration for you. Each call to __iter__ should return a fresh iterator so the object supports multiple simultaneous loops.
class Playlist:
def __init__(self, tracks):
self._tracks = list(tracks)
def __iter__(self):
yield from self._tracks
for track in Playlist(["a", "b"]):
print(track)Objects that compare equal must have equal hashes, and an object's hash must never change while it's in a dict or set. Defining __eq__ without __hash__ makes instances unhashable (Python sets __hash__ to None), which is correct for mutable objects and a bug for value objects.
Practical guidance: hash the same immutable tuple of fields you compare in __eq__, or use frozen dataclasses which generate both consistently.
Environment variables carry deployment-specific values, parsed and validated once at startup into a typed settings object (Pydantic Settings is the common choice); secrets come from the platform's secret manager, never the repo; and the config object is passed explicitly rather than imported globally, which keeps tests trivial.
The anti-patterns worth naming: config.py files mutated at import time, scattered os.environ reads deep in the call stack, and .env files leaking into images.
Reproduce or observe first: logs and traces around the slow path, then py-spy dump/top on the live process for a no-restart flame view (is it CPU, a lock, or waiting on I/O?). Correlate with recent deploys and dependency changes; check the usual suspects: N+1 queries, missing timeouts, retry storms, GIL-bound CPU work on a threaded server.
Then fix at the right layer with a measurement to confirm. The technical sequence matters more than any single tool: observe, hypothesize, verify, fix, confirm.
Key point: The methodology is; tools support the evidence.
A weak reference points to an object without increasing its reference count, so it doesn't keep the object alive. When the object is collected, the weakref returns None.
Use cases: caches and registries that shouldn't immortalize their entries (WeakValueDictionary), observer patterns where listeners may die, and breaking reference cycles between parent and child structures.
Options in ascending effort: ctypes/cffi to call existing shared libraries, Cython to compile annotated Python to C, and hand-written CPython extension modules (or Rust via PyO3, the modern favorite). NumPy-style vectorization often removes the need entirely.
Drop down when a profiled hot loop is pure computation that resists vectorization, and remember extensions can release the GIL around pure-C sections, which is how libraries achieve real threaded parallelism.
Clarify the requirements first (per-user or global, burst tolerance, distributed or single process), then pick the algorithm: a token bucket gives smooth rates with burst capacity and is a few lines with a timestamp and a counter. Single process: store buckets in a dict keyed by user with a lock. Distributed: the state moves to Redis with atomic Lua or INCR+EXPIRE, because per-process memory can't see global traffic.
The closing step is the operational edges: clock behavior, what to return on rejection (429 plus Retry-After), and metrics on rejects. Structuring the answer clarify, algorithm, storage, edges is what the question is really scoring.
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens, self.updated = capacity, time.monotonic()
def allow(self):
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return True
return FalsePython wins when developer speed and readability matter more than raw runtime performance, which is most application, data, and scripting work. It trades some execution speed for a gentler syntax and a deeper ecosystem, so teams ship faster and hire more easily. Where it loses is CPU-bound parallelism (the GIL) and cold-start-sensitive or memory-tight environments, where Go or a compiled language fits better. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Language | Typing | Best at | Watch out for |
|---|---|---|---|
| Python | Dynamic | Data, ML, scripting, fast prototyping | CPU parallelism (GIL), raw speed |
| Java | Static | Large enterprise systems, Android | Verbosity, slower to prototype |
| JavaScript | Dynamic | Browser and full-stack web | Type quirks, ecosystem churn |
| Go | Static | Concurrent services, low-latency tools | Smaller data/ML ecosystem |
Prepare in layers, and practice out loud. Most Python rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Python interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages, Python included. These questions reflect what actually gets asked and evaluated inside the interviews we host for 5,000+ HR teams.
See how the AI Coding Interviewer works