Strong Python answers explain the object model underneath the syntax. Name what is allocated or shared, what protocol runs, and which production bug the distinction prevents.
Question set
100 detailed answers
01What's the difference between mutable and immutable types?
junior
Short answer: Mutable objects can be changed in place without creating a new object (list, dict, set, bytearray); immutable ones cannot (int, float, str, bytes, tuple, frozenset, bool, None). Any "change" to an immutable object creates a new object.
In depth: Mutability is about whether an object can change its contents while keeping the same id() (its address in memory).
# Immutable: the operation creates a NEW object
s = "hello"
print(id(s))
s += " world" # a new string is created
print(id(s)) # id changed — it's a different object
# Mutable: the same object is changed
lst = [1, 2, 3]
print(id(lst))
lst.append(4) # change in place
print(id(lst)) # same id
Why it matters:
- Hashability. Only objects that are immutable (by content) can be used as
dictkeys orsetelements. If a key could change, its hash would drift and it couldn't be found. - Safety when sharing. An immutable object can be safely shared across threads/functions — no one can corrupt it.
- Function arguments. Python passes a reference to the same object: a function can mutate a mutable argument's contents, but rebinding the local parameter does not rebind the caller's variable.
⚠️ Gotcha: A tuple is immutable, but if it holds a list, that list can still be changed:
t = ([1, 2], 3)
t[0].append(99) # OK! the list inside is mutable
print(t) # ([1, 2, 99], 3)
# t[0] = [...] # THIS is a TypeError — you can't reassign an element
Also, hash(([1,2], 3)) will fail — a tuple is unhashable if it contains an unhashable element.
02What's the difference between list and tuple?
junior
Short answer: list is mutable, tuple is not. A tuple is hashable (if all its elements are hashable), uses less memory, and is created slightly faster. Use list for homogeneous, mutable collections and tuple for fixed, heterogeneous records.
In depth:
| Property | list | tuple |
|---|---|---|
| Mutability | yes | no |
| Hashability | no | yes (if elements are hashable) |
| Memory | more (reserves room for growth) | less |
| Creation | slower | faster (a literal can be cached) |
| Semantics | collection of homogeneous elements | record with a fixed structure |
import sys
print(sys.getsizeof([1, 2, 3])) # ~88 bytes
print(sys.getsizeof((1, 2, 3))) # ~64 bytes
Why list is bigger: it keeps an over-allocation — it reserves space in advance for future appends to amortize the cost of growth to O(1). A tuple is fixed and needs no spare room.
When to choose which:
tuple— when the count and meaning of the elements are fixed: coordinates(x, y), returning multiple values from a function, a dictionary key.list— when the collection grows/changes/gets sorted.
⚠️ Gotcha: "a tuple is faster" is true only for creating a literal and for access, not magically for everything. And remember nested mutable objects: a tuple guarantees the immutability of references, not of the objects they point to.
03What is hashability and why is it needed?
junior
Short answer: An object is hashable if it has a __hash__() that returns a stable value over its lifetime, plus an __eq__(). Hashability is needed to be a dict key or a set element.
In depth: The contract: if a == b, then hash(a) == hash(b). The reverse need not hold (collisions are allowed). By default, user-defined objects are hashed by id().
hash((1, 2)) # ok
hash(frozenset())) # ok
# hash([1, 2]) # TypeError: unhashable type: 'list'
If you override __eq__, then __hash__ automatically becomes None (the object stops being hashable) — Python protects the contract. You need to set __hash__ explicitly:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
__hash__ = lambda self: hash((self.x, self.y))
⚠️ Gotcha: Don't base __hash__ on a mutable field. If a key object changes after insertion, its hash drifts and d[key] won't find it — it gets stuck in the dictionary forever.
04How is dict implemented internally?
middle
Short answer: A dict is a hash table. The key is hashed, and the hash is used to compute an index into an array of slots. Collisions are resolved by open addressing with probing. Since 3.6 the implementation is "compact": the data lives in a dense array of entries, with a separate index array — this is what gives insertion order and saves memory.
In depth: The lookup/insert algorithm:
hash(key)is computed.- The low bits of the hash give the slot index.
- If the slot is empty, store there. If it's occupied by another key (a collision), probe the next slots in a special sequence (perturbation) until an empty or matching slot is found.
- Comparison by
==: two keys are equal only if both the hash and__eq__match.
The compact scheme (CPython 3.6+):
indices: [_, 1, _, 0, _, 2] # array of indices into entries
entries: [(hash, key0, val0), # dense, in insertion order
(hash, key1, val1),
(hash, key2, val2)]
entries is stored in insertion order → iteration follows that order. indices is sparse and is what you search through.
When the table is roughly 2/3 full, a resize happens (usually growing ~2-4x) with rehashing — amortized, insertion stays O(1).
⚠️ Gotcha: Don't confuse "insertion order is guaranteed" with "sorted". A dictionary does not sort keys. For a sorted traversal, use sorted(d).
05Does dict guarantee insertion order?
middle
Short answer: Yes. In CPython 3.6 it was an implementation detail, and since Python 3.7 it's an official language guarantee: a dict preserves the insertion order of keys.
In depth:
d = {}
d["b"] = 1
d["a"] = 2
d["c"] = 3
list(d) # ['b', 'a', 'c'] — insertion order, not alphabetical
Overwriting the value of an existing key does not change the key's position. Deleting and re-inserting moves the key to the end.
Before 3.7, collections.OrderedDict was used for guaranteed order. OrderedDict is still useful today: it has move_to_end(), popitem(last=False), and its == is order-sensitive (a regular dict's is not).
{"a": 1, "b": 2} == {"b": 2, "a": 1} # True — order doesn't affect equality
06When is dict access O(1), and when is it O(n)?
middle
Short answer: On average, all operations (get/set/del/in) are O(1). In the worst case, with massive collisions, it's O(n), because you have to walk the probing chain.
In depth: O(1) relies on a uniform distribution of hashes. If many keys' hashes land in the same slots, probing degenerates and each operation starts walking over many occupied slots.
When this actually happens:
- A bad
__hash__on a user-defined class (e.g., always returning0). - A hash-flooding attack — an attacker picks keys with the same hash. CPython defends against this with string hash randomization (
PYTHONHASHSEED).
class Bad:
def __hash__(self): return 0 # all in one slot
def __eq__(self, o): return self is o
# Inserting N such objects degenerates to O(n) per operation → O(n^2) total
⚠️ Gotcha: The amortized cost of append/insertion is also O(1), but a single insertion that triggers a resize is more expensive — that's normal and averages out.
07What's the difference between set and frozenset, and what operations exist on sets?
middle
Short answer: Both are unordered collections of unique hashable elements built on a hash table. set is mutable, frozenset is not (which is why a frozenset is itself hashable and can be an element of another set or a dictionary key).
In depth:
a = {1, 2, 3}
b = {2, 3, 4}
a | b # {1, 2, 3, 4} union
a & b # {2, 3} intersection
a - b # {1} difference
a ^ b # {1, 4} symmetric difference
a <= b # False subset (issubset)
a >= b # False superset (issuperset)
a.isdisjoint(b) # False no common elements?
Mutating methods exist only on set: add, discard, remove, pop, update (|=), intersection_update (&=), etc.
Why frozenset: when you need a set as a dictionary key or as an element of another set.
graph = {frozenset({"a", "b"}): 5} # an undirected edge as a key
A x in s check is O(1) on average, unlike x in list (O(n)). This is the main reason to choose a set for membership tests.
⚠️ Gotcha: The literal {} is an empty dict, not a set! An empty set is set(). And the order in a set is undefined — don't rely on it.
08Why is the default argument `def f(x=[])` dangerous?
middle
Short answer: The default value is evaluated once, when the function is defined, not on each call. A mutable default (a list, a dict) is shared across all calls and accumulates changes.
In depth:
def add(item, target=[]):
target.append(item)
return target
add(1) # [1]
add(2) # [1, 2] — NOT [2]! the same list
add(3) # [1, 2, 3]
The list is created once and lives in add.__defaults__. Every call without target mutates the same object.
How to fix it — a None sentinel:
def add(item, target=None):
if target is None:
target = [] # a fresh list on each call
target.append(item)
return target
Why None specifically, and not if not target: an empty passed-in list [] is also falsy, and if not target would wrongly replace it. is None distinguishes "nothing was passed" from "an empty value was passed".
⚠️ Gotcha: The same applies to dict, set, and calls like def f(t=time.time()) — the time "freezes" at the moment the function is defined. Sometimes a mutable default is used deliberately as a cache — but that's an intentional trick, not an accident.
09How does Python pass arguments to a function?
junior
Short answer: "Pass by object reference" (a.k.a. "call by sharing"). A reference to the same object is passed into the function. A mutable object can be changed from inside, but rebinding the name inside cannot.
In depth: A name in Python is a label on an object. On a call, the parameter becomes a new label on the same object.
def mutate(lst):
lst.append(99) # change the object itself → visible outside
def rebind(lst):
lst = [0, 0, 0] # rebind the LOCAL name → not visible outside
x = [1, 2]
mutate(x); print(x) # [1, 2, 99]
rebind(x); print(x) # [1, 2, 99] — unchanged
So it's neither "pass by value" (there's no copy) nor classic "pass by reference" (you can't reassign the caller's variable). The value of the reference is passed.
⚠️ Gotcha: Immutable objects create the illusion of "pass by value":
def inc(n): n += 1 # n = n + 1 creates a new int, the name is local
a = 5; inc(a); print(a) # 5 — unchanged
To "return" a change to an immutable, return the value via return.
10What's the difference between `is` and `==`?
junior
Short answer: == compares values (it calls __eq__). is compares identity — whether it's the same object in memory (id(a) == id(b)).
In depth:
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — values are equal
a is b # False — different objects
c = a
a is c # True — the same object
is is used to compare against singletons: None, True, False.
if x is None: ... # correct
if x == None: ... # works, but bad style and slower
⚠️ Gotcha: is sometimes "accidentally" matches == because CPython reuses some small integers and interns some strings. Therefore if x is 256 may work in one context and fail in another. Never use is to compare the values of numbers/strings.
11What are small-int caching and string interning?
middle
Short answer: CPython pre-creates and reuses int objects in the range −5..256, so they are identical (is). Short "identifier-like" strings are interned (a single instance is stored). This is an optimization you can't rely on logically.
In depth:
a = 256; b = 256
a is b # True — from the small-int cache
a = 257; b = 257
a is b # False (usually) — separate objects
The range −5..256 was chosen as the "most common" numbers. This is a CPython implementation detail.
String interning: strings that look like identifiers (letters, digits, _) and string literals are often interned by the compiler, so is can return True for them. To force it, use sys.intern():
import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
a is b # True — guaranteed to be one object
Why intern: it speeds up comparison (it first checks is, which is instant) and saves memory when there are many identical strings (e.g., keys during parsing).
⚠️ Gotcha: The behavior of is for int/str depends on whether they are literals or computed results, on the Python version, and on context (REPL vs module). Never compare numbers and strings with is.
12What's the difference between shallow and deep copying?
middle
Short answer: A shallow copy (copy.copy) creates a new outer object but embeds the same references to the inner objects. A deep copy (copy.deepcopy) recursively copies the entire object tree.
In depth:
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original) # or original[:] / list(original)
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] — the inner list is shared!
deep = copy.deepcopy(original)
deep[0].append(77)
print(original) # unchanged — everything was copied
Ways to make a shallow copy: lst[:], list(lst), dict(d), set(s), copy.copy().
deepcopy is more expensive and can handle cyclic references (it remembers already-copied objects in memo to avoid looping forever). Copying behavior for your own classes is customized via __copy__ and __deepcopy__.
⚠️ Gotcha: b = a is not a copy at all — it's a second label on the same object. And a shallow copy of a list of dicts is a frequent source of "why did the original change?"
13What are *args and **kwargs, and what kinds of arguments are there?
junior
Short answer: *args collects extra positional arguments into a tuple; **kwargs collects extra keyword ones into a dict. In a signature, / separates positional-only arguments and * separates keyword-only ones.
In depth:
def f(a, b, /, c, d, *args, e, f=10, **kwargs):
...
# ^^^^ ^^^^^ ^^^^^^^^^
# positional- regular keyword-only (after *)
# only (before /)
- Before
/— positional-only (can't be passed by name). - After
*or*args— keyword-only. *args→tuple,**kwargs→dict.
Unpacking at the call site:
def add(a, b, c): return a + b + c
nums = [1, 2, 3]
add(*nums) # unpack a list into positionals
d = {"a": 1, "b": 2, "c": 3}
add(**d) # unpack a dict into keyword args
Why positional-only (/): so that parameter names don't become part of the public API and can be renamed. Why keyword-only (*): to force the caller to write func(verbose=True) for readability and to guard against a mixed-up order.
⚠️ Gotcha: The names args/kwargs are a convention; what matters is the * and **. And the order when unpacking multiple sources must be valid: f(*a, *b, **c, **d) is allowed, but the keys in **c/**d must not conflict.
14What are comprehensions and why are they faster than a loop?
middle
Short answer: They're a compact syntax for building collections. There are list [...], set {...}, dict {k: v ...} comprehensions, and the generator expression (...). They're faster than a manual loop with append because iteration and construction happen at the C level, without repeatedly looking up the append method.
In depth:
squares = [x*x for x in range(10)] # list
even_set = {x for x in range(10) if x % 2 == 0} # set
sq_map = {x: x*x for x in range(5)} # dict
gen = (x*x for x in range(10)) # generator (lazy!)
Why faster than a loop: in a for ... : result.append(x) loop, on each iteration the interpreter looks up the append attribute and makes a Python function call. A list comprehension uses specialized bytecode and doesn't pay for the repeated method lookup.
A generator expression (...) doesn't build the whole collection at once — it yields elements one at a time. This saves memory on large data:
sum(x*x for x in range(10**7)) # doesn't create a list of 10M elements
⚠️ Gotcha: Don't overuse nested comprehensions — [[... for ...] for ...] quickly becomes unreadable. And the loop variable in a comprehension does not leak into the enclosing scope (unlike in Python 2): after [x for x in ...] there's no x outside.
15What is a closure and what is late binding?
senior
Short answer: A closure is a nested function that "remembers" the variables of the enclosing function, even after it has finished. Late binding means the value of a free variable is taken at the moment the closure is called, not when it's created — hence the classic gotcha in loops.
In depth:
def make_counter():
count = 0
def inc():
nonlocal count # without this, count would be local
count += 1
return count
return inc
c = make_counter()
c(); c() # 1, then 2 — count is preserved in the closure
The captured variables live in c.__closure__ (cell cells).
The classic gotcha — late binding in a loop:
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — NOT [0, 1, 2]!
All the lambdas capture the same variable i, and by the time they're called the loop is already over and i == 2.
The fix is a default argument (capturing the value now) or a factory:
funcs = [lambda i=i: i for i in range(3)] # i=i is bound at creation time
[f() for f in funcs] # [0, 1, 2]
⚠️ Gotcha: The same thing happens in for loops, not just in comprehensions. If you use the loop variable inside event handlers/callbacks, always pin it via a default argument or functools.partial.
16How do decorators work?
middle
Short answer: A decorator is a function that takes a function and returns a new function (usually a wrapper). @dec over def f is syntactic sugar for f = dec(f).
In depth:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
result = func(*args, **kwargs)
print("done")
return result
return wrapper
@log_calls
def greet(name):
return f"Hello, {name}"
# Equivalent to: greet = log_calls(greet)
greet("Anya")
wrapper takes *args, **kwargs so it fits any signature. Inside, it can: do something before/after, change the arguments/result, not call the original at all (e.g., a cache hit), or catch exceptions.
Decorators are used for cross-cutting concerns: logging, caching (functools.lru_cache), timing, permission checks, retries, registering handlers.
⚠️ Gotcha: Without functools.wraps, the wrapper hides the original's metadata (__name__, __doc__, annotations, and __wrapped__). That breaks introspection, documentation, and some testing tools, so wrappers should normally use @functools.wraps(func).
17How do you write a decorator with arguments, and why functools.wraps?
senior
Short answer: A decorator with arguments is a function that returns a decorator (three levels of nesting). functools.wraps copies the original function's metadata onto the wrapper so that introspection, documentation, and debugging don't break.
In depth:
import functools
def repeat(times): # 1) decorator factory
def decorator(func): # 2) the actual decorator
@functools.wraps(func) # preserve __name__, __doc__, etc.
def wrapper(*args, **kwargs): # 3) the wrapper
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
"""Sends a ping."""
print("ping")
# Equivalent to: ping = repeat(times=3)(ping)
Why wraps:
print(ping.__name__) # 'ping' (with wraps) instead of 'wrapper'
print(ping.__doc__) # 'Sends a ping.' instead of None
Without it, help(ping), logs, serialization, and frameworks (that read __name__) would see wrapper — this breaks debugging and sometimes logic. wraps also sets __wrapped__, letting you reach the original.
⚠️ Gotcha: It's easy to get confused by the levels: @repeat (without parentheses) would pass the function itself as times. A decorator with arguments is always called with parentheses: @repeat(3).
18What are generators and how does yield work?
middle
Short answer: A generator is a function with yield that returns a generator object. On each next(), it runs up to the next yield, hands back a value, and "freezes" its state (local variables, the execution point) until the next call. These are lazy, single-use iterators.
In depth:
def countdown(n):
while n > 0:
yield n # hand back a value and pause
n -= 1
gen = countdown(3)
next(gen) # 3
next(gen) # 2
for x in gen: # continues from where it left off: 1
print(x)
The key difference from a regular function: yield doesn't destroy the stack frame, it freezes it. Local variables are preserved between calls.
The benefit is memory and laziness. Infinite/huge sequences without storing them in memory:
def naturals():
n = 0
while True:
yield n
n += 1 # an infinite stream, O(1) memory
Generators support .send(value) (pass a value in, where it becomes the result of yield), .throw() (raise an exception inside), and .close().
⚠️ Gotcha: A generator is single-use — once fully traversed it's exhausted, and a second for yields nothing. To traverse again, either recreate the generator or collect into a list (but then you lose the memory savings).
19Why do you need yield from?
senior
Short answer: yield from iterable delegates iteration to a subgenerator/iterable: it forwards all of its values outward, and also correctly proxies send, throw, and the value from return. It's shorter and more correct than a manual loop.
In depth:
def chain(*iterables):
for it in iterables:
yield from it # instead of: for x in it: yield x
list(chain([1, 2], (3, 4), "ab")) # [1, 2, 3, 4, 'a', 'b']
yield from matters not just as sugar: it establishes a transparent channel between the outer consumer and the inner generator — send()/throw() reach the subgenerator, and its return value becomes the result of the yield from expression:
def inner():
yield 1
return 99 # this value is "returned" upward
def outer():
result = yield from inner()
print("received:", result) # received: 99
It's used in recursive tree traversal, generator composition, and (historically) was the basis for coroutines before async/await.
⚠️ Gotcha: yield from works with any iterable, but the full power of delegation (send/return) only comes with generators.
20What is an iterator and the iteration protocol?
middle
Short answer: An iterable can hand out an iterator via __iter__(). An iterator implements __next__(), which returns the next element or raises StopIteration. A for loop, under the hood, calls iter() and then next() in a loop, catching StopIteration.
In depth:
class Squares:
def __init__(self, n):
self.n = n
self.i = 0
def __iter__(self): # makes the object an iterator
return self
def __next__(self):
if self.i >= self.n:
raise StopIteration # signal that "elements are exhausted"
val = self.i ** 2
self.i += 1
return val
for x in Squares(4):
print(x) # 0 1 4 9
What for x in obj does:
it = iter(obj) # obj.__iter__()
while True:
try:
x = next(it) # it.__next__()
except StopIteration:
break
... # loop body
The difference between iterable and iterator: a list is iterable but not an iterator (no __next__); each iter(list) gives a fresh iterator. A generator is both an iterable and an iterator at once (its __iter__ returns itself), and is therefore single-use.
⚠️ Gotcha: If a class's __iter__ returns self, the object becomes single-use: two for loops in a row over it won't work. To make it reusable, __iter__ must return a new iterator.
21What is a context manager and how does with work?
middle
Short answer: A context manager guarantees that "entry" and "exit" run around a block — typical for managing resources (files, locks, connections). with calls __enter__() on entry and __exit__() on exit, including when an exception occurs.
In depth:
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # this value goes into `as`
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.4f}s")
return False # False → the exception is NOT suppressed
with Timer() as t:
sum(range(10**6))
__exit__ receives information about the exception (or three Nones if there wasn't one). If __exit__ returns a "truthy" value, the exception is suppressed (usually you don't do this without a reason).
Via contextlib it's shorter:
from contextlib import contextmanager
@contextmanager
def open_file(path):
f = open(path)
try:
yield f # everything before yield = __enter__, after = __exit__
finally:
f.close() # runs even if an exception occurs in the block
Why: to guarantee the release of a resource. with open(...) will close the file even if an exception is raised inside the block — unlike a manual f.close(), which can be skipped.
⚠️ Gotcha: In @contextmanager you need try/finally around yield; otherwise, if an exception occurs in the with body, the code after yield (the cleanup) won't run.
22How does exception handling work (try/except/else/finally)?
middle
Short answer: try is the protected code, except handles specific exceptions, else runs if there was no exception, and finally always runs (for guaranteed cleanup). Exceptions form a class hierarchy rooted at BaseException.
In depth:
try:
x = int(input())
except ValueError as e: # a specific type — good
print("not a number:", e)
except (KeyError, IndexError): # several types at once
print("access problem")
else:
print("success:", x) # only if try ran without an exception
finally:
print("always runs") # cleanup
The hierarchy (simplified):
BaseException
├── SystemExit, KeyboardInterrupt, GeneratorExit # do NOT catch with a broad except
└── Exception
├── ArithmeticError → ZeroDivisionError
├── LookupError → KeyError, IndexError
├── ValueError, TypeError, OSError, ...
except Exception catches "ordinary" errors but not KeyboardInterrupt/SystemExit — that's correct. A bare except: catches everything, including Ctrl-C — almost always bad.
Custom exceptions:
class ValidationError(Exception):
"""A domain validation error."""
raise ValidationError("field is required")
Your own exceptions inherit from Exception (not from BaseException) and form a domain hierarchy, so the caller can catch either the specific case or the package's base class.
⚠️ Gotcha: A finally with a return/break overrides the exception and the value from try — it's easy to swallow an error unnoticed. And else exists so that the "on success" code doesn't accidentally fall under except handling (which it would if it were at the end of try).
23What are EAFP and LBYL?
concept
Short answer: LBYL ("Look Before You Leap") — first check the condition, then act. EAFP ("Easier to Ask Forgiveness than Permission") — act and catch the exception. Python leans toward EAFP.
In depth:
# LBYL
if "key" in d:
value = d["key"]
else:
value = default
# EAFP — more Pythonic
try:
value = d["key"]
except KeyError:
value = default
Why EAFP is preferred:
- No race condition. Between the
if os.path.exists(p)check andopen(p), the file can disappear. EAFP checks and acts atomically. - Less duplication of the check and the action logic.
- In Python, exceptions are cheap on the "happy path" (when there are none), though expensive when raised.
When LBYL is appropriate: when the check is cheap, an exception is likely and isn't an error (in which case constant exceptions are expensive), or the check expresses intent more clearly.
⚠️ Gotcha: Don't catch too broadly in EAFP: an except Exception around d[key] would also hide bugs in the code computing the value. Catch a specific type.
24How are strings, Unicode, and bytes implemented?
middle
Short answer: str is an immutable sequence of Unicode characters (code points). bytes is an immutable sequence of bytes (0–255). You move between them via encode()/decode() with a specified encoding (usually UTF-8). f-strings are a convenient way to format.
In depth:
s = "café"
b = s.encode("utf-8") # b'caf\xc3\xa9' — 5 bytes (é = 2 bytes in UTF-8)
b.decode("utf-8") # 'café'
len(s) # 4 characters
len(b) # 5 bytes
str stores characters, bytes stores raw bytes. You can't mix them ("a" + b"b" → TypeError). Data from the network/a file arrives as bytes — you decode it into str; to a file/the network you write bytes — you encode str.
f-strings:
name, score = "Anya", 0.857
f"{name}: {score:.1%}" # 'Anya: 85.7%' formatting on the fly
f"{score=}" # 'score=0.857' debug output (3.8+)
f"{name!r}" # calls repr()
f-strings are faster than %-formatting and .format() because they compile to efficient bytecode.
String immutability means concatenation in a loop, s += x, creates new objects each time → O(n²). The right way is "".join(parts):
result = "".join(str(x) for x in range(1000)) # fast, a single pass
⚠️ Gotcha: One Unicode character ≠ one byte (UTF-8 is variable-length) and not even ≠ one "visible mark" (emoji with modifiers, combining diacritics). When working with encodings, never guess — specify it explicitly.
25What is truthiness, and what do or and and return?
junior
Short answer: In a boolean context, objects decide their own "truthiness" via __bool__ (or __len__). Falsy: False, None, 0, 0.0, "", [], {}, (), set(). and/or are short-circuit operators that return an operand (not necessarily a bool).
In depth:
bool([]) # False — empty collections are falsy
bool([0]) # True — non-empty, even with a zero inside
bool("0") # True — a non-empty string!
or returns the first truthy operand (or the last one if all are falsy); and returns the first falsy (or the last if all are truthy):
"" or "default" # 'default'
"value" or "default" # 'value'
0 and 5 # 0 — and stopped at a falsy
2 and 5 # 5
This is used for defaults and short-circuiting:
name = user_input or "Anonymous" # a default if empty
config and config.get("debug") # safe access, won't fail on None
⚠️ Gotcha: x or default replaces any falsy value, including 0, "", False. If 0 is a valid value, use x if x is not None else default. The classic bug: count = get_count() or 10 turns a legitimate 0 into 10.
26How does unpacking work?
junior
Short answer: You can assign to several names at once from an iterable. * collects the "rest" into a list. This gives you an elegant swap and easy destructuring of structures.
In detail:
a, b, c = [1, 2, 3] # a=1, b=2, c=3
a, b = b, a # swap without a temporary variable
first, *rest = [1, 2, 3, 4] # first=1, rest=[2, 3, 4]
first, *mid, last = [1,2,3,4] # first=1, mid=[2,3], last=4
*init, last = [1, 2, 3] # init=[1,2], last=3
(a, b), c = (1, 2), 3 # nested unpacking
The swap a, b = b, a works like this: the right-hand side is first assembled into a tuple (b, a), then unpacked — so no temporary variable is needed and the order is correct.
* in unpacking always captures a list (even from a tuple), and there can be only one per level.
⚠️ Gotcha: The number of names must match the number of elements (if there's no *), otherwise you get ValueError: too many values to unpack or not enough values. And unpacking a generator exhausts it.
27How does slicing work?
junior
Short answer: seq[start:stop:step] returns a new subsequence. start is included, stop is not. Negative indices count from the end. A slice never goes out of bounds (it doesn't raise IndexError).
In detail:
s = [0, 1, 2, 3, 4, 5]
s[1:4] # [1, 2, 3] stop is excluded
s[:3] # [0, 1, 2] from the start
s[3:] # [3, 4, 5] to the end
s[::2] # [0, 2, 4] every second element
s[::-1] # [5,4,3,2,1,0] reverse
s[-2:] # [4, 5] last two
s[10:20] # [] out of bounds — empty, no error
A slice creates a shallow copy (for list/str/tuple). s[:] is the idiom for copying a list. You can assign to a slice of a mutable sequence:
lst = [1, 2, 3, 4]
lst[1:3] = [20, 30, 40] # [1, 20, 30, 40, 4] — replacement that changes the length
del lst[::2] # delete every second element
Under the hood s[1:4] is s[slice(1, 4)]; you can pass a slice object explicitly.
⚠️ Gotcha: s[::-1] reverses, but it's a copy — to reverse a list in place use .reverse(). And a negative step flips the meaning of start/stop: s[5:1:-1] goes from index 5 down to 2.
28What is LEGB, and why do we need global and nonlocal?
senior
Short answer: Name lookup follows the LEGB rule: Local → Enclosing → Global → Built-in. global lets you assign to a module-level variable from inside a function, and nonlocal lets you assign to a variable in an enclosing (non-global) function.
In detail:
x = "global" # G
def outer():
x = "enclosing" # E (for inner)
def inner():
x = "local" # L
print(x) # 'local' — the nearest level is used
inner()
# Built-in (B): len, print, range, ... — the last level in the lookup
Reading proceeds from the bottom up through LEGB. But assignment makes a name local by default, so:
counter = 0
def bump():
counter += 1 # UnboundLocalError! counter is treated as local,
# but read before assignment
Fixed with a declaration:
def bump():
global counter # "work with the global one"
counter += 1
def make():
n = 0
def inc():
nonlocal n # "work with n from make", not global and not local
n += 1
return n
return inc
⚠️ Gotcha: Even a single assignment somewhere in a function makes the name local throughout the entire function body — hence the UnboundLocalError even before the assignment line. And overusing global is a sign of poor architecture; usually it's better to return a value or use a class.
29Why is Python slow?
concept
Short answer: CPython interprets bytecode, types are resolved at runtime (dynamic dispatch), everything is a heap object (overhead from boxing and reference counting), and the GIL prevents CPU-bound threads from executing bytecode in parallel. This is the price you pay for flexibility and development speed.
In detail: The main reasons:
- Bytecode interpretation. The source is compiled to bytecode that a virtual machine executes — there's no native compilation to the processor (as in C/Rust).
- Dynamic typing.
a + brequires figuring out the types at runtime and finding the right__add__. Compiled languages know the types ahead of time and generate a direct instruction. - Everything is a heap object. Even an
intis an object with a header, a reference count, and a type. A simplex + 1creates a new object instead of mutating a register. - GIL (Global Interpreter Lock). Only one thread executes bytecode at any given moment → for CPU-bound tasks, multithreading gives no speedup (you need processes or native extensions).
What people do about it: PyPy (JIT compilation), C extensions and numpy (heavy computation moves into C), Cython, numba, multiprocessing (multiprocessing), async for I/O-bound work. Since Python 3.11+ the interpreter has been noticeably sped up (specializing adaptive interpreter), and 3.12+/3.13 are moving toward optionally disabling the GIL.
⚠️ Gotcha: "Python is slow" is about CPU-bound work. For I/O-bound work (network, disk, database) interpreter speed hardly matters — the bottleneck is waiting, and here Python is quite efficient (especially with asyncio).
30What does it mean that Python is an interpreted and dynamic language?
concept
Short answer: "Interpreted" means the code is executed by a virtual machine line by line / via bytecode, without a separate phase of compilation to machine code. "Dynamic" means types are bound to values rather than to variables, and are checked/resolved at runtime; the structure of objects can be changed during execution.
In detail: What "interpreted" means (more precisely — bytecode-compiled + interpreted):
# Source → compiled to bytecode (.pyc) → executed by the CPython VM
import dis
dis.dis("a + b") # shows the bytecode: LOAD_NAME, BINARY_OP, ...
There's no separate build step into an executable; running = compiling to bytecode + interpretation.
What "dynamic" means:
x = 5 # x references an int
x = "now a string" # same name — a different type, that's fine
# Duck typing: behavior matters, not the class
def total(items):
return sum(items) # works with anything that's iterable and addable
# Objects can be modified at runtime
class A: pass
a = A()
a.new_attr = 123 # added an attribute on the fly
A name is just a label that can point to an object of any type. The type is checked at the moment of the operation (duck typing: "if it quacks like a duck...").
Upsides: flexibility, conciseness, fast development, metaprogramming. Downsides: type errors surface at runtime (hence the usefulness of type hints + mypy), and the overhead of being dynamic (see "why is it slow").
⚠️ Gotcha: Type hints (def f(x: int) -> str) are not checked by the interpreter at runtime — they're hints for humans and static analyzers. Python remains dynamic: f("a string") will run just fine.
31Why do we even need generators?
concept
Short answer: To process large or infinite streams of data lazily, with constant memory, and to build composable processing pipelines.
In detail: Three main reasons:
- Memory. A list of 100 million numbers won't fit; a generator yields one at a time:
def read_lines(path):
with open(path) as f:
for line in f: # the file is read line by line, not all into memory
yield line.rstrip()
- Laziness / infiniteness. You can describe an infinite sequence and take only what you need:
import itertools
firsts = list(itertools.islice(naturals(), 5)) # [0,1,2,3,4]
- Pipeline composition. Generators chain together without intermediate lists:
nums = (int(x) for x in read_lines("data.txt"))
evens = (x for x in nums if x % 2 == 0)
total = sum(evens) # data "flows" one item at a time, memory O(1)
This changes the model from "load everything → process" to "process in a stream as data arrives" — critical for big data and streams.
⚠️ Gotcha: For laziness you pay with single-use consumption and the inability to index or call len(). And deferred evaluation hides errors until the moment of iteration, which makes debugging harder.
32Why do we even need decorators?
concept
Short answer: To add behavior to functions/methods/classes without touching their code — an implementation of the DRY principle for cross-cutting functionality (logging, caching, authorization, retries, timing, registration).
In detail: Decorators extract the repetitive "wrapping" from many functions into one place:
import functools
def cache(func):
store = {}
@functools.wraps(func)
def wrapper(*args):
if args not in store:
store[args] = func(*args) # compute once
return store[args]
return wrapper
@cache
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
Without the decorator you'd have to write the caching logic inside every function. The decorator separates "what the function does" from "how to wrap it."
Where you see it in practice:
@functools.lru_cache— memoization.- Flask/FastAPI
@app.route(...)/@app.get(...)— registering handlers. @property,@staticmethod,@classmethod— built-in decorators.@dataclass— generating__init__,__repr__, etc.- Permission/retry/timeout decorators in production.
This aligns with the "open for extension, closed for modification" principle: behavior is grown via wrappers, the original function is left unchanged.
⚠️ Gotcha: A decorator changes the object that the name refers to; without functools.wraps you lose introspection, and multiple decorators are applied from the bottom up (@a above @b = a(b(f))) — order matters.
33What does "in Python everything is an object" mean?
concept
Short answer: Any entity in Python — a number, a string, a function, a class, a module, the type itself — is an object: it has an identity (id), a type (type), and a value. Everything has a reference, everything lives on the heap, and everything can be passed as an argument, put into a list, or assigned to a variable.
In detail: At the CPython level every object is a PyObject struct with two mandatory fields: a reference count (ob_refcnt) and a pointer to the type (ob_type). There are no "primitives" like in Java: even the int 5 is a full-fledged object on the heap.
def f(): pass
# functions are objects, they have attributes
f.custom = 42
print(f.custom) # 42
print(type(f)) # <class 'function'>
# classes are also objects (instances of the metaclass type)
class A: pass
print(type(A)) # <class 'type'>
B = A # a class can be assigned to a variable
print(isinstance(A, object)) # True
# even type is an object
print(type(type)) # <class 'type'>
From this follows uniformity: classes can be created at runtime (type('X', (), {})), functions can be stored in dictionaries, types can be passed as values. This is the foundation of Python's dynamism.
⚠️ Gotcha: "Everything is an object" doesn't mean "everything is mutable." int, str, tuple, frozenset are objects, but immutable. And "everything is an object" doesn't negate the fact that a variable is just a name-reference, not a container: assignment copies the reference, not the value.
34How does `type()` differ from `isinstance()`?
junior
Short answer: type(x) returns the exact type of an object, ignoring inheritance; isinstance(x, T) checks whether x is an instance of T or a subclass of it. For type checks you almost always want isinstance.
In detail:
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(type(d) is Dog) # True
print(type(d) is Animal) # False — exact type, no inheritance
print(isinstance(d, Animal)) # True — takes the hierarchy into account
print(isinstance(d, (Dog, int))) # True — you can pass a tuple of types
isinstance additionally accounts for virtual inheritance via ABCs (the metaclass's __instancecheck__), whereas type() is does not:
from collections.abc import Sequence
print(isinstance([], Sequence)) # True (list is registered as a Sequence)
print(type([]) is Sequence) # False
⚠️ Gotcha: type(x) == bool versus isinstance(x, int): bool is a subclass of int, so isinstance(True, int) → True. If you need to distinguish bool from numbers, check type(x) is int. Also don't use type(x) == SomeType — prefer is, since type comparison should go by identity.
35How does `__new__` differ from `__init__`?
middle
Short answer: __new__ is (effectively) a static method that creates and returns a new instance; __init__ initializes an already-created instance and returns nothing. __new__ is called first, then __init__ (only if __new__ returned an instance of this class).
In detail: When you call C(*args), the metaclass roughly does obj = C.__new__(C, *args); if isinstance(obj, C): C.__init__(obj, *args).
class C:
def __new__(cls, *args, **kwargs):
print("new", cls)
instance = super().__new__(cls) # object.__new__ creates an empty object
return instance
def __init__(self, value):
print("init", value)
self.value = value
c = C(10) # new <class 'C'> ; init 10
__new__ is needed for immutable types (you can't mutate in __init__, the object is already fixed), singletons, factories, and subclassing int/str/tuple:
class PositiveInt(int):
def __new__(cls, value):
if value < 0:
raise ValueError("must be >= 0")
return super().__new__(cls, value) # the value is set here
⚠️ Gotcha: If __new__ returns an object of a different class (not a subclass of cls), __init__ won't be called. A common mistake is forgetting return in __new__: then it returns None and the constructor hands back None. And __new__ is implicitly static: its first argument is cls, not self.
36How does `__repr__` differ from `__str__`?
junior
Short answer: __repr__ is the unambiguous "technical" representation for the developer (ideally valid Python code to recreate the object); __str__ is the human-readable representation for the user. str()/print use __str__, and if it's absent they fall back to __repr__.
In detail:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Point(1, 2)
print(str(p)) # (1, 2) -> __str__
print(repr(p)) # Point(x=1, y=2) -> __repr__
print(p) # (1, 2)
print([p]) # [Point(x=1, y=2)] — containers call __repr__ on elements
Rule: __str__ is optional, but __repr__ is worth always defining — it's what you see in the debugger, logs, and REPL. The reverse doesn't work: defining only __str__ doesn't give you a proper repr.
⚠️ Gotcha: Containers (list, dict) always call __repr__ on their elements when printed, not __str__. So print([p]) will show repr, even if you have a nice __str__. Also: f"{p}" → __str__, while f"{p!r}" → __repr__.
37Why, when overriding `__eq__`, do you also need to override `__hash__`?
senior
Short answer: The contract: if a == b, then hash(a) == hash(b). Hash containers (dict, set) first look up by hash, then compare via __eq__. If you override __eq__ but not __hash__, objects with different hashes will never be compared, and the logic of "equal → same key" breaks. That's why Python, when you define __eq__, automatically makes the class unhashable (__hash__ = None).
In detail:
class P:
def __init__(self, x): self.x = x
def __eq__(self, other):
return isinstance(other, P) and self.x == other.x
# __hash__ is automatically = None!
a = P(1)
{a} # TypeError: unhashable type: 'P'
To keep the object hashable and consistent:
class P:
def __init__(self, x): self.x = x
def __eq__(self, other):
return isinstance(other, P) and self.x == other.x
def __hash__(self):
return hash(self.x) # equal objects -> the same hash
Contract rules:
a == b⇒hash(a) == hash(b)(mandatory).- The reverse is not required: different objects may have the same hash (a collision is normal).
- The hash must stay constant over the object's lifetime ⇒ hash only by immutable fields.
⚠️ Gotcha: If you hash by a mutable field and then change it, the object "gets lost" in a set/dict — it ends up in the wrong bucket, and x in s returns False even though the object is there. Also: you can explicitly set __hash__ = None to forbid hashing, or inherit the parent's __hash__ via __hash__ = SomeBase.__hash__.
38How does `__call__` work, and what is a callable?
middle
Short answer: __call__ makes a class instance callable like a function: obj() translates into type(obj).__call__(obj). An object is "callable" if its type defines __call__. Functions, classes, and methods are themselves callable precisely because their types implement __call__.
In detail:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
print(double(10)) # 20
print(callable(double)) # True
When you write C() to create an instance — that's a call to type(C).__call__(C), i.e. type.__call__, which internally invokes __new__ and __init__. This explains how a metaclass can intervene in object creation.
Uses: stateful function-objects (counters, memoization), class-based decorators, factories, partial application.
⚠️ Gotcha: __call__ is looked up on the type, not on the instance. Assigning obj.__call__ = lambda: ... won't, in the ordinary case, make obj() work — special methods for implicit operations are taken from the class, bypassing the instance's __dict__.
39How does `__getattr__` differ from `__getattribute__`?
senior
Short answer: __getattribute__ is called on every attribute access (obj.x) and implements the entire normal lookup mechanism. __getattr__ is a fallback, called only when the normal lookup fails to find the attribute (an AttributeError would otherwise be raised). You usually want to override __getattr__ — it's safe; __getattribute__ is dangerous and rare.
In detail:
class Proxy:
def __init__(self, data):
self._data = data
def __getattr__(self, name):
# called only if name isn't found through the normal path
print("fallback for", name)
return self._data.get(name)
p = Proxy({"a": 1})
print(p._data) # normal lookup, __getattr__ is NOT called
print(p.a) # fallback for a ; 1 — no attribute, so we go into __getattr__
__getattribute__ intercepts absolutely everything:
class Loud:
def __getattribute__(self, name):
print("access", name)
return super().__getattribute__(name) # you must delegate!
⚠️ Gotcha: Inside __getattribute__ you can't write self.attr or self.__dict__[...] directly — that causes recursion (calling __getattribute__ again). Use super().__getattribute__(name) or object.__getattribute__(self, name). In __getattr__, accessing real attributes is safe, but accessing a nonexistent attribute inside __getattr__ leads to infinite recursion.
40How does attribute lookup work in detail?
senior
Short answer: On obj.x, type(obj).__getattribute__ fires. The order (simplified): first a data descriptor is searched in the class's MRO → then obj.__dict__ → then a non-data descriptor or a regular class attribute along the MRO → if nowhere found, __getattr__ is called, otherwise AttributeError.
In detail: The exact algorithm of object.__getattribute__:
- Walk the type's MRO, find
x. If it's a data descriptor (has__set__/__delete__) — returndescr.__get__(obj, type). It has the highest priority. - Otherwise, look in
obj.__dict__— if present, return it as is. - Otherwise, if a non-data descriptor (only
__get__) is found in the class — returndescr.__get__(obj, type). - Otherwise, if a regular attribute is found in the class — return it.
- Otherwise, call
type(obj).__getattr__(obj, 'x')if it exists; otherwiseAttributeError.
class D: # non-data descriptor
def __get__(self, obj, owner): return "from descriptor"
class C:
attr = D()
c = C()
print(c.attr) # from descriptor (step 3)
c.__dict__['attr'] = "shadow"
print(c.attr) # shadow — the instance overrides a non-data one (step 2 > 3)
Priority hierarchy: class data descriptor > instance __dict__ > non-data descriptor/class attribute > __getattr__.
⚠️ Gotcha: This is why a property (a data descriptor) can't be "overridden" by an instance attribute, while a regular method (a non-data descriptor) can be shadowed by putting a same-named key in the instance's __dict__. This also explains why __slots__ (data descriptors) and property take priority over same-named entries in __dict__.
41How do `__getitem__`, `__setitem__`, `__contains__` work?
junior
Short answer: They implement the syntax for indexing and membership testing: obj[k] → __getitem__, obj[k] = v → __setitem__, del obj[k] → __delitem__, k in obj → __contains__.
In detail:
class Grid:
def __init__(self): self._d = {}
def __getitem__(self, key): return self._d[key]
def __setitem__(self, key, value): self._d[key] = value
def __delitem__(self, key): del self._d[key]
def __contains__(self, key): return key in self._d
g = Grid()
g[(0, 0)] = "X" # __setitem__
print(g[(0, 0)]) # __getitem__ -> X
print((0, 0) in g) # __contains__ -> True
If __contains__ isn't defined, in falls back to __iter__ (iterating and comparing) or to __getitem__ with integer indices 0,1,2…. __getitem__ also makes the object iterable via the old protocol if there's no __iter__.
class Squares:
def __getitem__(self, i):
if i > 3: raise IndexError
return i * i
print(list(Squares())) # [0, 1, 4, 9] — iteration via __getitem__
⚠️ Gotcha: Slices obj[1:5] pass a slice(1, 5, None) object to __getitem__, not an int — you have to handle it yourself. And "old-style" iteration via __getitem__ stops on IndexError; any other exception will break the loop.
42How is operator overloading implemented (`__add__` and the reflected versions)?
middle
Short answer: Binary operators map to dunder methods: + → __add__, - → __sub__, * → __mul__, and so on. For the case where the left operand doesn't know how to work with the right one, there are reflected versions (__radd__, __rmul__), and for += there are in-place ones (__iadd__).
In detail:
class Vec:
def __init__(self, x, y): self.x, self.y = x, y
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)
def __mul__(self, k): # Vec * number
return Vec(self.x * k, self.y * k)
def __rmul__(self, k): # number * Vec
return self.__mul__(k)
def __repr__(self):
return f"Vec({self.x}, {self.y})"
print(Vec(1, 2) + Vec(3, 4)) # Vec(4, 6) -> __add__
print(3 * Vec(1, 2)) # Vec(3, 6) -> __rmul__ (int has no __mul__ for Vec)
The mechanics: for a + b Python tries type(a).__add__(a, b); if it returns NotImplemented, it tries type(b).__radd__(b, a). You must return exactly the NotImplemented singleton (not raise an exception) to give the reflected method a chance.
__iadd__ (for a += b) should mutate the object in place when possible and return self; if it's absent, a += b becomes a = a + b.
⚠️ Gotcha: If in __add__ you raise a TypeError for a foreign type instead of return NotImplemented, you block the reflected method of the second operand and break the symmetry of operations. Also for subclasses: if the right operand is a subclass of the left one and overrides __radd__, Python will call it first.
44How are `__slots__` implemented and why are they needed?
senior
Short answer: __slots__ defines a fixed set of instance attributes and disables creation of __dict__ on the object. Under the hood, a data descriptor (member_descriptor) is created for each slot, storing the value at a fixed offset inside the object's struct rather than in a dictionary. This saves memory and slightly speeds up access.
In detail:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
print(p.x) # 1
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
print(p.__dict__) # AttributeError: no __dict__
print(type(Point.x)) # <class 'member_descriptor'>
What it gives you:
- Memory: instead of a dictionary (tens to hundreds of bytes + growth) — a compact array of slots in the object itself. Critical with millions of instances.
- Speed: access via a descriptor by offset is slightly faster than a dict lookup.
- Protection from typos: you can't accidentally create a new attribute.
Limitations and inheritance:
- You can't assign attributes outside
__slots__(unless you add'__dict__'to the slots — then the savings are lost). - If at least one class in the hierarchy does not declare
__slots__, instances will get a__dict__, and the savings vanish. - Slots aren't inherited "by subtraction": in a subclass declare only the new attributes; repeating a parent's slot creates a hidden duplicate and wastes memory.
- It doesn't combine with a class variable of the same name (the slot takes the name).
- Slots are incompatible with some things without explicitly adding
'__weakref__'(by default slots also remove weak-reference support).
class Base:
__slots__ = ("a",)
class Child(Base):
__slots__ = ("b",) # only the new one; a is inherited as a slot
⚠️ Gotcha: The most common one — adding __slots__ to one class but forgetting about a parent/child without slots: a __dict__ appears, and all the savings silently disappear. Slots also break code that relies on obj.__dict__ (some serializers, monkey-patching). pickle of slotted objects works, but requires care with __getstate__/__setstate__.
45How does an object's `__dict__` differ from a class's `__dict__`?
middle
Short answer: instance.__dict__ holds the attributes of a specific instance (a regular mutable dict). Class.__dict__ holds the class's attributes — methods, class variables, descriptors — and it's a mappingproxy (read-only).
In detail:
class C:
cls_var = 10
def method(self): pass
c = C()
c.inst_var = 5
print(c.__dict__) # {'inst_var': 5}
print('method' in C.__dict__) # True — methods live in the class
print(type(C.__dict__)) # <class 'mappingproxy'> (read-only)
C.new_attr = 1 # you change the class via an attribute, not via __dict__
Dynamically adding attributes works precisely through instance.__dict__: the assignment c.x = 1 puts a key in the instance's dictionary. Lookup, meanwhile, reads the instance first, then the class along the MRO (see attribute lookup).
⚠️ Gotcha: A mutable class variable (for example, items = [] at the class level) is shared among all instances — an assignment via self.items.append(...) modifies the shared object. Whereas self.items = [...] creates an instance attribute that shadows the class one. Also, Class.__dict__ can't be modified directly (mappingproxy), only via setattr/attribute assignment.
46How much do `__slots__` actually save on memory?
senior
Short answer: For an object with a few attributes, slots remove the overhead __dict__ (in CPython this often saves ~100–200+ bytes per instance, depending on the version and the number of fields). Across millions of objects this is a difference of hundreds of megabytes.
In detail: A back-of-the-envelope comparison (orders of magnitude, CPython):
import sys
class WithDict:
def __init__(self, x, y): self.x, self.y = x, y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y): self.x, self.y = x, y
a = WithDict(1, 2)
b = WithSlots(1, 2)
# the object itself + its __dict__
size_dict = sys.getsizeof(a) + sys.getsizeof(a.__dict__)
size_slots = sys.getsizeof(b)
print(size_dict, size_slots) # e.g. ~152+ vs ~56 bytes (numbers depend on the version)
For the dict variant, memory = the object's size + a separate dictionary (which also grows with slack). For the slotted one, everything is in one compact struct, with no dictionary at all. The exact figures changed from version to version (in newer CPython, instance dictionaries are optimized with a "key-sharing dict," which narrows the gap, but slots are still more compact).
⚠️ Gotcha: sys.getsizeof(obj) for a dict-based object does not account for the size of the associated __dict__ — you have to add it manually, otherwise the savings look smaller than they are. And don't optimize with slots prematurely: the gain is noticeable with large numbers of homogeneous objects, and the cost is a loss of flexibility.
47What is a descriptor, and how do data and non-data descriptors differ?
senior
Short answer: A descriptor is an object that defines the behavior of attribute access via __get__/__set__/__delete__, when placed as a class attribute. A data descriptor implements __set__ or __delete__ (and usually __get__); a non-data descriptor has only __get__. The difference is in priority: a data descriptor overrides the instance's __dict__, a non-data one does not.
In detail:
class LoggedAttr:
def __set_name__(self, owner, name): # learn the attribute's name
self.name = "_" + name
def __get__(self, obj, owner):
if obj is None: return self
return getattr(obj, self.name)
def __set__(self, obj, value): # the presence of __set__ => a data descriptor
print("set", value)
setattr(obj, self.name, value)
class C:
x = LoggedAttr()
c = C()
c.x = 5 # set 5 (__set__ called)
print(c.x) # 5
Signatures:
__get__(self, obj, objtype)—objis the instance (orNonewhen accessed via the class).__set__(self, obj, value).__delete__(self, obj).__set_name__(self, owner, name)— called at class creation, gives the attribute's name.
Priority (from attribute lookup): data descriptor > instance __dict__ > non-data descriptor.
⚠️ Gotcha: Storing the value in the descriptor itself (self.value = value) is a mistake: there's one descriptor per class, so the value becomes shared across all instances. Store it in obj.__dict__ (via the __set_name__-derived name) or in a slot. And remember: descriptors work only when they live in the class, not in the instance.
48How is `property` built?
middle
Short answer: property is a built-in data descriptor. Its __get__ calls the getter, __set__ the setter, __delete__ the deleter. The @x.setter and @x.deleter decorators return a new property object with updated functions.
In detail:
class Temperature:
def __init__(self, celsius=0):
self._c = celsius
@property
def celsius(self): # getter
return self._c
@celsius.setter
def celsius(self, value): # setter
if value < -273.15:
raise ValueError("below absolute zero")
self._c = value
@celsius.deleter
def celsius(self): # deleter
del self._c
@property
def fahrenheit(self): # computed read-only property
return self._c * 9 / 5 + 32
t = Temperature(25)
t.celsius = 30 # calls the setter
print(t.fahrenheit) # 86.0
Since property is a data descriptor, it takes priority over __dict__, so obj.celsius always goes through the getter, even if someone tries to put celsius into the instance's dictionary.
⚠️ Gotcha: Without @celsius.setter the property is read-only — an attempt at t.celsius = 5 gives AttributeError. A common mistake is naming the backing field the same as the property (self.celsius = ... inside __init__), which calls the setter recursively, or causes infinite recursion if the setter itself accesses the property. Use a separate name (_c).
49Why are ordinary methods, `classmethod`, and `staticmethod` descriptors?
senior
Short answer: A function is a non-data descriptor: its __get__ returns a bound method, automatically supplying self. classmethod and staticmethod are separate descriptors that change what gets supplied: classmethod binds cls, staticmethod binds nothing.
In detail:
class C:
def method(self): return self # function -> bound on access
@classmethod
def cm(cls): return cls
@staticmethod
def sm(): return "no binding"
c = C()
print(c.method) # <bound method C.method of <__main__.C ...>>
print(c.method()) # c — self supplied via __get__
print(C.cm()) # <class 'C'> — cls supplied
print(c.sm()) # no binding — staticmethod just returns the function
How it works: c.method → finds the function in the class → it's a non-data descriptor → function.__get__(c, C) returns a bound method that, when called, passes c as the first argument. That's why methods can be "shadowed" by an instance attribute (a non-data descriptor's priority is lower than __dict__).
classmethod.__get__ returns a method bound to the class; staticmethod.__get__ returns the original function without binding.
A comparison of the three kinds:
- instance method: receives
self; works with a specific object. - classmethod: receives
cls; alternative constructors, factories, working with class state; works correctly with inheritance (clsis the actual subclass). - staticmethod: receives nothing; a logically related function in the class's namespace.
⚠️ Gotcha: If you put a function directly into obj.__dict__ (not into the class), it will not become a method — __get__ fires only for class attributes. And classmethod is better than staticmethod in factories, because cls points to the actual subclass: SubClass.create() will create a SubClass, not the base class.
50Why do we even need descriptors?
concept
Short answer: Descriptors are the mechanism underlying property, methods, classmethod/staticmethod, __slots__, functools.cached_property, and ORM fields (Django/SQLAlchemy). They let you describe attribute-access logic once (validation, lazy computation, typing) and reuse it declaratively across many fields.
In detail: Without descriptors, each validated field would require a separate property with duplicated code. A descriptor encapsulates the logic and is applied repeatedly:
class Typed:
def __init__(self, expected): self.expected = expected
def __set_name__(self, owner, name): self.name = "_" + name
def __get__(self, obj, owner):
return self if obj is None else getattr(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected):
raise TypeError(f"expected {self.expected}")
setattr(obj, self.name, value)
class Person:
name = Typed(str) # one piece of logic — many fields
age = Typed(int)
This is the "infrastructure" technique of frameworks: declarative models, lazy properties, validator descriptors.
⚠️ Gotcha: Descriptors are a powerful but "non-obvious" mechanism: the code in __get__/__set__ fires "magically" on an ordinary obj.x, which makes debugging harder. Often a property or __init_subclass__/dataclass solves the task more simply; a full-blown descriptor is justified when the logic is genuinely reused across many fields/classes.
51What is MRO and how does C3 linearization work?
senior
Short answer: MRO (Method Resolution Order) is the order in which Python looks up attributes/methods across base classes. For new-style classes it is computed by the C3 linearization algorithm, which guarantees that a class comes before its parents and that the relative order of parents from the declaration is preserved. View it via Cls.__mro__ or Cls.mro().
In depth: C3 builds the linearization L[C] by merging the linearizations of the parents and the list of parents:
L[C] = C + merge(L[B1], L[B2], ..., [B1, B2, ...])
At each step merge takes the "head" of the first list that does not appear in the "tail" of any other list, and removes it everywhere.
The classic diamond:
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
class D(B, C):
pass
print([c.__name__ for c in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
print(D().who()) # "B" — B comes before C in the MRO
A appears in the MRO only once (after B and C), even though it is reachable via two paths — and that is exactly what solves the "diamond problem": the common ancestor is called once and after all of its descendants.
⚠️ Gotcha: If the orders are incompatible, C3 cannot build a linearization and Python raises TypeError: Cannot create a consistent method resolution order. Example: class X(A, B) and class Y(B, A), then class Z(X, Y) — the order of A and B is contradictory. The fix is to keep the order of base classes consistent across the whole hierarchy.
52How does `super()` actually work?
senior
Short answer: super() does not "call the parent" — it delegates to the next class in the MRO relative to the current class and the current instance. Without arguments, super() inside a method becomes super(__class__, self), using the hidden __class__ cell. This is what makes cooperative multiple inheritance work correctly.
In depth:
class A:
def __init__(self): print("A");
class B(A):
def __init__(self): print("B"); super().__init__()
class C(A):
def __init__(self): print("C"); super().__init__()
class D(B, C):
def __init__(self): print("D"); super().__init__()
D()
# D
# B
# C <- super() in B went not to A, but to C — the next one in D's MRO!
# A
The key point: super().__init__() inside B called C, not A, because D's MRO is D → B → C → A → object. super() walks this list, so A.__init__ runs exactly once. This is "cooperative inheritance" — every class calls super(), and the chain traverses the whole MRO without duplicates.
super(Type, obj) — the explicit form: it looks up the one after Type in type(obj).__mro__.
⚠️ Gotcha: In a cooperative scheme, all classes in the chain must call super().__init__() with compatible signatures, otherwise the chain breaks or crashes. If you think of super() as "call the direct parent," the result will surprise you with diamond inheritance. Also, super() without arguments works only inside a class method (the __class__ cell is required); outside a method, use the explicit form.
53What is a mixin and how do you use it correctly?
middle
Short answer: A mixin is a class that adds a specific behavior through multiple inheritance, but is not meant to be instantiated on its own. It does not hold its own state "as an entity," it merely contributes methods (e.g., serialization, logging, comparison).
In depth:
class ReprMixin:
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{type(self).__name__}({attrs})"
class ComparableMixin:
def __eq__(self, other):
return type(self) == type(other) and vars(self) == vars(other)
class User(ReprMixin, ComparableMixin):
def __init__(self, name): self.name = name
print(User("Ann")) # User(name='Ann')
Good manners: mixins go to the left of the main class (higher in the MRO, so they can override behavior), they have a narrow responsibility, they rely on the interface of the "host" class, and they often use super() for cooperativeness.
⚠️ Gotcha: Multiple inheritance with conflicting methods and __init__ signatures is a source of subtle MRO bugs. Don't make "fat" mixins with state and dependencies on one another. Often composition (aggregating an object) is clearer than mixin inheritance.
54What is a metaclass and why is it needed?
senior
Short answer: A metaclass is the "class of a class": the thing a class is itself an instance of. By default the metaclass is type. When you write class C: ..., Python calls type(name, bases, namespace) and creates a class object. A custom metaclass lets you intervene in the creation of a class: change/validate its attributes, register it, enforce conventions.
In depth: type is itself both a class and a metaclass (type(type) is type). A class declaration is equivalent to:
def make_class():
return type("C", (object,), {"x": 1}) # name, bases, namespace
C = make_class()
print(C.x) # 1
A custom metaclass via __new__/__init__:
class Meta(type):
def __new__(mcls, name, bases, ns, **kwargs):
# ns is the class body dict; you can inspect/modify it
ns["created_by"] = "Meta"
return super().__new__(mcls, name, bases, ns)
class A(metaclass=Meta):
pass
print(A.created_by) # Meta
print(type(A)) # <class 'Meta'>
Real-world uses: ORMs (a Django Model gathers fields from the class body), plugin registration, enums, ABCMeta (checking abstract methods), API enforcement (forbidding/requiring methods), singletons.
The chain: instance → class (type(obj)) → metaclass (type(cls)). A subclass's metaclass is taken as the metaclass of one of the base classes (it must be compatible — a subclass of the metaclasses of all bases).
⚠️ Gotcha: A class's metaclass must be a subclass of the metaclasses of all its bases, otherwise you get TypeError: metaclass conflict. Metaclasses are rarely needed: 95% of tasks are solved more simply with __init_subclass__, class decorators, or dataclass. "If you're not sure whether you need a metaclass, you don't" (Tim Peters).
55How does `__init_subclass__` replace metaclasses?
middle
Short answer: __init_subclass__ is a hook (an implicit classmethod) that is called on the parent every time a subclass is created. It covers most of the "lightweight" scenarios that people used to write metaclasses for (registration, validation, defaults), without the complexity of metaclasses.
In depth:
class Plugin:
registry = {}
def __init_subclass__(cls, /, key=None, **kwargs):
super().__init_subclass__(**kwargs)
if key:
Plugin.registry[key] = cls # auto-register the subclass
class JsonPlugin(Plugin, key="json"):
pass
print(Plugin.registry) # {'json': <class '__main__.JsonPlugin'>}
Arguments from the class declaration (key="json") are forwarded into __init_subclass__. There's also __set_name__ for descriptors and class decorators — together they cover almost everything that used to require a metaclass.
When you still need a metaclass: when you need to change the class creation process itself (the order/type of the namespace via __prepare__), override the metaclass's __call__ (controlling instantiation), or __instancecheck__/__subclasscheck__.
⚠️ Gotcha: Don't forget to call super().__init_subclass__(**kwargs) — otherwise you break cooperativeness with other classes in the hierarchy. __init_subclass__ is called for subclasses, but not for the class where it is defined itself.
56How do abstract base classes (ABCs) work?
middle
Short answer: abc.ABC (metaclass ABCMeta) lets you declare methods abstract via @abstractmethod. A class with unimplemented abstract methods cannot be instantiated. An ABC defines an interface contract and supports virtual registration (register) and a custom __subclasshook__.
In depth:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, data): ...
@abstractmethod
def load(self): ...
Storage() # TypeError: Can't instantiate abstract class
class FileStorage(Storage):
def save(self, data): ...
def load(self): ... # all abstract methods implemented
FileStorage() # OK
Virtual inheritance without subclassing:
from collections.abc import Sequence
Sequence.register(MyType) # MyType now has isinstance(x, Sequence) == True
collections.abc uses __subclasshook__ so that isinstance(obj, Iterable) returns True for any object with __iter__, without explicit registration.
⚠️ Gotcha: @abstractmethod must be combined with @property/@classmethod in the right order (@property on top). Registration (register) makes isinstance/issubclass true, but does not check that the methods are actually implemented — it's a "promise" that is easy to break. Also, abstractness is checked only at instantiation, not when the subclass is defined.
57What is a Protocol and structural typing?
middle
Short answer: typing.Protocol defines an interface by structure ("duck typing" in the type system): a class is considered compatible if it has the required methods/attributes, without explicit inheritance. It's static typing in the "if it quacks like a duck" style.
In depth:
from typing import Protocol, runtime_checkable
class Readable(Protocol):
def read(self) -> str: ...
def consume(src: Readable) -> str:
return src.read()
class File: # does NOT inherit from Readable
def read(self) -> str: return "data"
consume(File()) # OK for the type checker — structurally compatible
Difference from ABCs:
- ABC (nominal): compatibility via explicit inheritance/registration.
- Protocol (structural): compatibility via matching structure; the class needn't inherit anything.
@runtime_checkable enables isinstance(obj, Readable) at runtime (it only checks for the presence of methods by name, not their signatures).
⚠️ Gotcha: A runtime_checkable isinstance only checks for the presence of attributes, not their signatures/types — it can give a false positive. Protocol is primarily a tool for static checking (mypy/pyright); at runtime, without @runtime_checkable, isinstance against it fails.
58How does reference counting work?
middle
Short answer: CPython counts the number of references to each object in the ob_refcnt field. When the counter drops to zero, the object is freed immediately. This is the primary memory management mechanism; the counter grows on assignment/adding to a container and drops on del/leaving scope.
In depth:
import sys
x = []
print(sys.getrefcount(x)) # >=2: one reference x + a temporary argument reference in getrefcount
y = x
print(sys.getrefcount(x)) # one more — y also references it
del y # the counter decreases
The advantage is determinism: the object is destroyed as soon as the last reference is lost (predictable resource release, __del__ is called on time). The downside is that reference counts alone cannot free a cycle: two objects still reference each other even after they become unreachable from the program. CPython therefore runs a separate generational cyclic collector. Updating reference counts also adds overhead and requires synchronization between threads.
⚠️ Gotcha: sys.getrefcount(x) always shows one more than the "real" count, because the function argument itself creates a temporary reference. Don't rely on exact numbers. And don't write code that depends on immediate release (__del__) — on PyPy/Jython there is no refcounting, objects are collected non-deterministically.
59Why do you need a garbage collector if you have reference counting?
senior
Short answer: Reference counting does not free reference cycles (objects referencing each other, whose refcount never drops to zero). For those there is a separate cyclic GC that works by generations and detects unreachable cycles.
In depth: Example of a leak without the GC:
import gc
a = {}
b = {}
a["b"] = b
b["a"] = a # cycle: each refcount >= 1 even after del
del a, b # objects are unreachable, but refcount != 0
gc.collect() # the cyclic GC finds and removes them
Generations (generational GC): new objects go into generation 0; survivors of a collection move to 1, then to 2. Young generations are collected more often (the hypothesis: most objects die young), old ones less often. The gc module:
import gc
gc.collect() # force a collection, returns the number collected
gc.disable() # disable the cyclic GC (refcounting keeps working)
gc.get_count() # generation counters
gc.get_threshold() # trigger thresholds
gc.set_threshold(700, 10, 10)
The cyclic GC tracks only container objects (those that can hold references): list, dict, class instances, etc. Simple objects (int, str) don't participate in cycles.
⚠️ Gotcha: A __del__ method on objects inside a cycle historically prevented collection (before Python 3.4 such cycles ended up in gc.garbage and were not freed). Now they are collected, but the order in which __del__ is called within a cycle is undefined. Leaks most often come from "live" references (global caches, closures, registration in collections), not from the GC. For debugging leaks: gc.get_objects(), gc.get_referrers(), tracemalloc, weakref to break cycles.
60What is string interning and the small-int cache?
middle
Short answer: CPython caches frequently used immutable objects so they can be reused: small integers from -5 to 256 are created once (singletons), and short identifier-like strings are interned. That's why a is b can be True for equal values — but this is an implementation detail, not a guarantee.
In depth:
a = 256
b = 256
print(a is b) # True — in the small-int cache
c = 257
d = 257
print(c is d) # often False — outside the cache (may differ in a REPL / single line)
s1 = "hello"
s2 = "hello"
print(s1 is s2) # usually True — string literals are interned
import sys
s3 = sys.intern("".join(["he", "llo"])) # explicit interning
Why: saving memory and speeding up comparison (you can compare by is/pointer). String interning is especially helpful with dict keys and identifiers.
⚠️ Gotcha: Never compare values with is (if x is 256). Use ==. Interning behavior depends on the version, the compilation context (one code block vs. interactive input), and the implementation. id() returns a unique identifier for an object during its lifetime, but after the object dies its id may be reused by another object — you can't compare ids saved across time.
61What is bytecode, .pyc, and how does CPython differ from PyPy?
senior
Short answer: CPython compiles source into an intermediate bytecode (instructions for a stack-based virtual machine) that the interpreter executes. The compiled bytecode is cached in .pyc files (__pycache__) to speed up re-imports. CPython is the reference implementation; PyPy is an alternative with a JIT; Cython compiles Python/an extended syntax into C.
In depth:
import dis
def f(x):
return x + 1
dis.dis(f)
# LOAD_FAST x
# LOAD_CONST 1
# BINARY_OP + (in newer versions)
# RETURN_VALUE
The CPython pipeline: source → AST → bytecode (code object, available as f.__code__.co_code) → execution by the virtual machine (the interpreter loop). A .pyc contains marshalled bytecode plus a header with a hash/timestamp of the source for cache invalidation. A .pyc is created when a module is imported (not for a script run directly).
Implementations:
- CPython — the reference, written in C, has a GIL, interprets bytecode (with recent optimizations — a specializing adaptive interpreter).
- PyPy — a JIT compiler, close to native speed on "hot" code; written in RPython; doesn't use refcounting (a different GC).
- Cython — translates Python (with optional typing) into C for speed and integration with C code.
- Others: Jython (JVM), GraalPy, MicroPython.
⚠️ Gotcha: Bytecode is not cross-version — a .pyc from a different Python version is incompatible (hence the version tag in the file name). Don't treat bytecode as "protection" for your source — it is easily disassembled (dis) and decompiled. And remember that the details being discussed (GIL, refcounting, small-int cache, interning) are about CPython, not the Python language in general.
62What is the difference between concurrency and parallelism?
junior
Short answer: Concurrency is about the structure of a program (managing several tasks that make progress, but not necessarily at the same time). Parallelism is about physical simultaneous execution on multiple cores.
In depth:
- Concurrency — tasks switch and make progress "in turns," creating the illusion of simultaneity. One cook prepares 3 dishes, switching between them. It can happen on a single core.
- Parallelism — tasks actually execute in the same physical second. Three cooks, each preparing their own dish. Requires multiple cores/processors.
Concurrency (1 core): Parallelism (2 cores):
CPU: A B A B A B CPU0: A A A A
CPU1: B B B B
In Python:
asyncioandthreading(due to the GIL) give concurrency, but not real parallelism for CPU code.multiprocessinggives real parallelism.
💡 "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." — Rob Pike.
⚠️ Gotcha: many people confuse the two and say "threads in Python run in parallel." For CPU-bound code that's not the case, because of the GIL.
63What is the GIL and why does it exist?
senior
Short answer: The GIL (Global Interpreter Lock) is an interpreter-level mutex in CPython that guarantees only one thread executes Python bytecode at any given moment. It's needed to simplify memory management (reference counting) and the thread-safety of internal structures.
In depth:
CPython manages memory via reference counting (ob_refcnt on every object). Incrementing/decrementing the counter is not an atomic operation at the processor level. Without a global lock, two threads simultaneously changing one object's counter would cause a race: the counter would "lose" an update, the object would be freed too early (use-after-free) or leak.
Solution options:
- Make every refcount atomic (via atomic instructions) — expensive, slows down single-threaded code.
- One global lock (the GIL) — simple, fast for single-threaded code. CPython chose this.
import sys
a = []
print(sys.getrefcount(a)) # the reference count; the GIL protects such operations
The GIL is a feature of CPython (the reference implementation). In Jython (JVM) and IronPython (.NET) there is no GIL — they use the runtime's garbage collection.
⚠️ Gotcha: The GIL is not part of the Python language, it's an implementation detail of CPython. The phrasing "Python has a GIL" is technically inaccurate.
64Why doesn't multithreading speed up CPU-bound tasks, but does speed up I/O-bound ones?
senior
Short answer: The GIL allows only one thread to execute bytecode. CPU-bound threads contend for the GIL and effectively run in turns (even worse, due to switching overhead). I/O-bound threads release the GIL for the duration of a blocking system call, so other threads can run in the meantime.
In depth:
CPU-bound — a thread constantly executes bytecode and holds the GIL. Two such threads on 4 cores still yield about 1 core's worth of useful work:
import threading, time
def cpu_task(n):
while n > 0:
n -= 1
N = 50_000_000
# Sequentially
t = time.perf_counter()
cpu_task(N); cpu_task(N)
print("seq:", time.perf_counter() - t)
# 2 threads — NOT faster (often slower due to GIL contention)
t = time.perf_counter()
threads = [threading.Thread(target=cpu_task, args=(N,)) for _ in range(2)]
for x in threads: x.start()
for x in threads: x.join()
print("threads:", time.perf_counter() - t)
I/O-bound — a thread calls read()/recv()/time.sleep() and gives up the GIL while waiting on the OS. While it sleeps, the GIL is free — another thread runs:
import threading, time
def io_task():
time.sleep(1) # blocking I/O (simulated); the GIL is released
t = time.perf_counter()
threads = [threading.Thread(target=io_task) for _ in range(10)]
for x in threads: x.start()
for x in threads: x.join()
print("10 threads of 1s each:", time.perf_counter() - t) # ~1s, not 10s
⚠️ Gotcha: for CPU-bound work you need multiprocessing, not threading. This is a classic interview "trap": "speed up hash computation with threads" — the answer is "threads won't help because of the GIL."
65When exactly is the GIL released?
middle
Short answer: The GIL is released (1) on blocking I/O operations and system calls, (2) in C extensions that explicitly release it (Py_BEGIN_ALLOW_THREADS), (3) forcibly every ~5 ms by a timer (the "switch interval").
In depth:
- I/O and syscalls.
socket.recv,file.read,time.sleep,os.system— CPython wraps the blocking call inPy_BEGIN_ALLOW_THREADS ... Py_END_ALLOW_THREADS, releasing the GIL while waiting on the OS. - Heavy C extensions (NumPy, lxml, compression) often release the GIL during the computation in C — which is why NumPy operations can actually be parallelized across threads.
- Switch interval — for long stretches of pure Python bytecode the interpreter periodically forces a thread to give up the GIL, to give others a chance:
import sys
print(sys.getswitchinterval()) # 0.005 (5 ms) by default
sys.setswitchinterval(0.01)
In Python 2 switching was "by number of bytecode instructions" (every ~100 ticks), in Python 3 it's by time (5 ms). This made scheduling fairer.
⚠️ Gotcha: time.sleep() releases the GIL (it's a blocking syscall), but a "busy-wait" loop while True: pass does not — it holds the GIL.
66What changes with the GIL in newer Python versions?
senior
Short answer: In 3.13 an experimental free-threaded CPython build appeared (PEP 703) — an interpreter without a GIL, where threads are genuinely parallel for CPU code. In 3.12 the foundation was laid: a per-interpreter GIL (PEP 684) — each sub-interpreter can have its own GIL.
In depth:
- Python 3.12, PEP 684 (per-interpreter GIL): state isolation lets each sub-interpreter have its own GIL. Through
interpreters(in 3.13 — theconcurrent.interpretersmodule, PEP 734) you can run multiple interpreters in one process with parallelism, but without the overhead of processes. - Python 3.13, PEP 703 (free-threading): a separate
python3.13tbuild without a GIL. Reference counting is replaced with a thread-safe variant (biased reference counting + atomic operations), with specialized allocation and object-level locks. CPU-bound threads truly scale across cores. - The cost: a single thread is slower (the overhead of atomicity); C extensions need to be adapted (declare
Py_mod_gil). - Python 3.14 moves free-threading toward officially supported status (out of "experimental").
# Check whether the GIL is disabled in a free-threaded build
python3.13t -c "import sys; print(sys._is_gil_enabled())"
💡 Strategically: the whole ecosystem stack is heading this way (NumPy, Cython are already adding support), but for an interview it's important to know that on the standard build the GIL is still there.
67When should you use threading?
junior
Short answer: For I/O-bound tasks with blocking libraries (network requests, disk, DB drivers without an async API), where threads spend most of their time waiting and releasing the GIL.
In depth:
import threading
def worker(name):
print(f"working {name}")
t = threading.Thread(target=worker, args=("A",))
t.start()
t.join() # wait for completion
# Via subclassing
class MyThread(threading.Thread):
def run(self):
print("in the thread")
MyThread().start()
Good scenarios:
- Many blocking HTTP requests via
requests(no async). - Reading files in parallel.
- GUI: a background thread so as not to block the interface.
Bad scenarios:
- CPU-bound computation → use
multiprocessing. - Thousands of concurrent connections → use
asyncio(threads are too memory-expensive).
💡 Threads are "parallelism of waiting," not "parallelism of computation" in CPython.
68What is the difference between Lock and RLock?
middle
Short answer: Lock is a simple mutex: a repeat acquire() by the same thread will deadlock. RLock (reentrant lock) is reentrant: a single thread can acquire it multiple times (it needs the same number of release() calls).
In depth:
import threading
lock = threading.Lock()
counter = 0
def increment():
global counter
for _ in range(100_000):
with lock: # critical section
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000 — without the lock it would be unpredictable
RLock — when a method under the lock calls another method that also takes the same lock:
rlock = threading.RLock()
def outer():
with rlock:
inner() # with a plain Lock this would be a deadlock
def inner():
with rlock:
pass
Other primitives: Semaphore (a counter of permits), Event (a signal flag), Condition (waiting on a condition), Barrier (synchronizing N threads).
⚠️ Gotcha: Lock.acquire() twice in one thread = permanent deadlock. If the code is recursive/reentrant — use RLock.
69What is a race condition and why is `x += 1` unsafe?
middle
Short answer: A race condition is when the result depends on the unpredictable order of thread execution. x += 1 is three bytecode operations (read, add, store); the GIL may switch threads between them, and the increment gets lost.
In depth:
import dis
def f():
x = 0
x += 1
dis.dis(f)
# LOAD_FAST x / LOAD_CONST 1 / BINARY_OP += / STORE_FAST x
Thread A reads x=5, a switch happens, thread B reads x=5, both write 6. One increment is lost. The GIL does not save you, because it can switch between bytecodes.
The fix is a lock or atomic structures (queue.Queue, itertools.count, operations under with lock).
⚠️ Gotcha: The GIL guarantees atomicity of a single bytecode instruction, but not of a sequence. +=, if x: x = ..., check-then-act — all races.
70Why do people say `dict[key] = value` is thread-safe, but checking `if key not in d: d[key]=...` is not?
senior
Short answer: A single insert/read operation is implemented in C and runs within a single bytecode instruction under the GIL — it's atomic. But a compound operation (check-then-act) consists of several instructions, between which a switch is possible → a race.
In depth:
Atomic (one C operation, not interrupted by the GIL):
d[k] = v,d.get(k),list.append(x),x = d[k],L.pop().
NOT atomic (several steps):
# Race: between the check and the assignment another thread could have inserted the key
if key not in d:
d[key] = compute() # ❌
# Race: read-modify-write
d[key] += 1 # ❌ (LOAD, ADD, STORE)
Safe:
with lock:
if key not in d:
d[key] = compute() # ✅
# or
d.setdefault(key, []).append(x) # setdefault is atomic
⚠️ Gotcha: don't rely on "atomicity thanks to the GIL" as a design — it's an implementation detail of CPython (in the free-threaded build the guarantees differ, it uses internal locks there). An explicit Lock is more reliable and more portable.
71What is a deadlock and how do you avoid it?
middle
Short answer: A deadlock is mutual blocking: thread A holds lock 1 and waits for lock 2, thread B holds lock 2 and waits for lock 1. Nobody makes progress. You avoid it with a single lock-acquisition order, timeouts, and minimizing held locks.
In depth:
import threading
lock1, lock2 = threading.Lock(), threading.Lock()
def thread_a():
with lock1:
with lock2: # waits for lock2
...
def thread_b():
with lock2: # holds lock2
with lock1: # waits for lock1 -> DEADLOCK
...
The Coffman conditions (all 4 are required): mutual exclusion, hold-and-wait, no preemption, circular wait. Remove any one — no deadlock.
Ways to avoid it:
- A single order of acquisition (always lock1 first, then lock2).
- Timeouts:
lock.acquire(timeout=1). RLockagainst self-blocking.- Reduce the holding region, use high-level
queue.Queue.
⚠️ Gotcha: forgetting release() on an exception — always use with lock:.
72How does multiprocessing get around the GIL?
junior
Short answer: Each process is a separate Python interpreter with its own GIL and its own memory. Processes run on different cores in genuine parallel, so they're suitable for CPU-bound work.
In depth:
from multiprocessing import Process
import os
def cpu_task(n):
s = sum(i*i for i in range(n))
print(os.getpid(), s)
if __name__ == "__main__": # important on Windows/macOS (spawn)
procs = [Process(target=cpu_task, args=(10_000_000,)) for _ in range(4)]
for p in procs: p.start()
for p in procs: p.join()
Start methods:
fork(the Linux default) — copies the process (copy-on-write), fast.spawn(Windows, the macOS default since 3.8+) — launches a fresh interpreter, re-imports the module → you needif __name__ == "__main__".forkserver— a separate server process for forking.
⚠️ Gotcha: without if __name__ == "__main__" under spawn — infinite recursive spawning of processes.
73Why are processes more expensive than threads?
middle
Short answer: A process is a separate address space: more expensive to create (especially spawn), more memory, and exchanging data requires serialization (pickle) and IPC. Threads share memory and are cheap, but are limited by the GIL.
In depth:
| Aspect | Thread | Process |
|---|---|---|
| Memory | shared | isolated |
| Creation | cheap (~KB of stack) | expensive (a new interpreter) |
| CPU parallelism | no (GIL) | yes |
| Data exchange | directly (locks needed) | pickle + IPC |
| Crash | takes down the whole process | isolated |
| Startup | microseconds | milliseconds–tens of ms |
💡 Rule of thumb: it makes sense to spawn roughly as many processes as cores (os.cpu_count()), not thousands. You can have hundreds of threads, and tens/hundreds of thousands of coroutines.
74How do processes exchange data and what is the pickling problem?
middle
Short answer: Via IPC: Queue, Pipe, Manager. All transferred objects are serialized via pickle, so non-serializable objects (lambdas, local functions, open sockets/files) can't be passed.
In depth:
from multiprocessing import Pool
def square(x):
return x * x
if __name__ == "__main__":
with Pool(processes=4) as pool:
print(pool.map(square, range(10))) # parallelized across processes
# apply_async / imap / starmap are also available
IPC mechanisms:
Queue— a thread-safe queue, under the hood a pipe + pickle.Pipe— a bidirectional channel between two processes.Manager— proxy objects (list,dict), synchronized between processes (slower, via a server process).
from multiprocessing import Process, Queue
def worker(q):
q.put("result")
if __name__ == "__main__":
q = Queue()
p = Process(target=worker, args=(q,)); p.start(); p.join()
print(q.get())
⚠️ Gotcha: pool.map(lambda x: x*x, data) will fail — a lambda can't be pickled. You need a module top-level function. Also, the pickle overhead can "eat up" the gain if the data is large and the work is small.
76When should you use what?
junior
Short answer: A single high-level API. ThreadPoolExecutor is for I/O-bound work (threads, shared GIL), ProcessPoolExecutor is for CPU-bound work (processes, bypassing the GIL). Switching between them is just a matter of swapping one class.
In detail:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
def fetch(url): ... # I/O
def crunch(n): ... # CPU
# I/O-bound
with ThreadPoolExecutor(max_workers=20) as ex:
futures = [ex.submit(fetch, u) for u in urls]
for fut in as_completed(futures):
print(fut.result())
# CPU-bound
with ProcessPoolExecutor() as ex: # defaults to os.cpu_count()
results = list(ex.map(crunch, data))
A Future gives you .result(), .done(), .add_done_callback(), .cancel(). as_completed iterates as results become ready; map preserves input order.
💡 In an interview: concurrent.futures is the recommended high-level interface instead of manually creating threads/processes. It integrates with asyncio via loop.run_in_executor.
⚠️ Gotcha: ProcessPoolExecutor also requires if __name__ == "__main__" and picklable functions. Exceptions inside a task surface at .result(), not at .submit().
77Why do we even need async if we have threads?
senior
Short answer: To efficiently serve many concurrent I/O operations on a single thread, without the memory cost and context switches of thousands of OS threads. It's the solution to the C10k problem — "how do you serve 10,000+ connections."
In detail:
Imagine a web server handling 10,000 concurrent clients, each of which mostly just waits (network, database):
- "Thread per connection" model: 10,000 OS threads. Each thread has a ~512 KB–8 MB stack → gigabytes of RAM. Switching contexts between them is done by the OS kernel — those are expensive syscalls. It doesn't scale.
- Async model: 1 thread + an event loop. When a coroutine waits on I/O, it "yields control" (
await), and the loop switches to another ready coroutine. The switch is just a function call in user space, not a syscall. Memory per coroutine is on the order of a few KB.
Key ideas:
- Cooperative multitasking: tasks voluntarily yield control at
awaitpoints (unlike preemptive multitasking with threads, where the OS can interrupt at any moment). - Savings: no thousands of thread stacks, no expensive kernel switches, no locks on every shared object (within a single thread there are no "between-instruction" races... but there are "between-await" ones, see below).
- Predictability: code only switches at
await, making it easier to reason about critical sections.
import asyncio
async def handle(client_id):
await asyncio.sleep(1) # simulating a network wait; the loop does other work
return client_id
async def main():
# 10,000 "connections" on a single thread
results = await asyncio.gather(*(handle(i) for i in range(10_000)))
print(len(results)) # ~1 second, not 10000 threads
asyncio.run(main())
💡 Async is about scaling I/O concurrency, not about computation speed.
78How does a single thread serve thousands of connections?
senior
Short answer: Because connections spend almost all their time waiting, not computing. The event loop, via an OS mechanism (epoll/kqueue/IOCP), watches all the sockets at once and activates only those coroutines whose data is ready. A single CPU has enough headroom to "serve" the switches between thousands of waiting tasks.
In detail:
Under the hood it's I/O multiplexing (the selectors module → epoll on Linux). Instead of "a thread waits on one socket" you get "a single thread waits on 10,000 sockets at once via epoll_wait and gets back the list of those that are ready":
loop: epoll_wait(all sockets) -> [sockets 3, 17, 952 are ready]
-> wake the coroutines bound to 3, 17, 952
-> run them until the next await
-> epoll_wait again
This works as long as handling a single event is short and non-blocking. The bottleneck isn't the number of connections, it's the total CPU work on one core.
⚠️ Gotcha: "one thread" = one core. For CPU load, or to use all cores, you run several processes each with an event loop (e.g. gunicorn/uvicorn with N workers).
79What does asyncio consist of?
junior
Short answer: The event loop is the scheduler that drives tasks. A coroutine is an async def function that can be suspended. await is a suspend/resume point. A Task is a coroutine scheduled to run in the loop.
In detail:
import asyncio
async def fetch(name, delay):
print(f"{name}: start")
await asyncio.sleep(delay) # yield control to the loop
print(f"{name}: done")
return name
async def main():
# run concurrently
results = await asyncio.gather(
fetch("A", 2),
fetch("B", 1),
)
print(results)
asyncio.run(main()) # creates the loop, runs main, closes the loop
async defcreates a coroutine function; calling it returns a coroutine object (nothing runs until you await it / run it in the loop).asyncio.run(coro)is the entry point: it creates the event loop, runs it, and closes it.
⚠️ Gotcha: calling fetch("A", 2) without await/create_task doesn't start anything and will produce RuntimeWarning: coroutine was never awaited.
80What exactly does the `await` operator do?
middle
Short answer: await suspends the current coroutine and returns control to the event loop until the awaited object (the awaitable) completes. Meanwhile the loop runs other tasks.
In detail:
await X requires X to be an awaitable: a coroutine, a Task, a Future, or an object with __await__. The mechanics: the coroutine is suspended, its state is saved (like a generator's), and the loop schedules a resume when the awaitable is ready.
async def main():
data = await fetch_data() # pause here, the loop keeps working
process(data) # continues once it's ready
Important: await does not make code parallel by itself. await a(); await b() runs sequentially. For concurrency you need gather/create_task:
# sequential (3 sec):
await sleep3(); await sleep2()
# concurrent (3 sec = max):
await asyncio.gather(sleep3(), sleep2())
⚠️ Gotcha: await yields control, so another coroutine can change shared state between a read and a later write after await. Races therefore exist even on a single-threaded event loop; protect the critical sequence with asyncio.Lock or redesign it so one task owns the mutable state.
81What's the difference between a coroutine, a Task, and a Future?
middle
Short answer: A coroutine is a description of work; on its own it doesn't run. A Future is a low-level "container for a future result." A Task is a subclass of Future that wraps a coroutine and schedules it to run in the event loop (it starts immediately/concurrently).
In detail:
import asyncio
async def work():
await asyncio.sleep(1); return 42
async def main():
coro = work() # coroutine — NOT started
task = asyncio.create_task(coro) # Task — scheduled and already running
print(isinstance(task, asyncio.Future)) # True (Task ⊂ Future)
result = await task # wait for the result
print(result)
asyncio.run(main())
- Future — a promise of a result, usually created by a library/the loop (
loop.create_future()), rarely by hand. It hasset_result/set_exception. - Task = Future + driving a coroutine. Created via
create_task/ensure_future. - A coroutine only actually starts running when it's wrapped in a Task or awaited via
await/gather.
💡 Analogy: a coroutine is the recipe; a Task is the cook who's been handed the recipe and told to cook now; a Future is the empty plate the dish will land on.
⚠️ Gotcha: create_task starts the coroutine immediately (on the loop's next step), whereas just calling work() does not.
82How do gather, create_task, and run differ?
middle
Short answer: run is the entry point (once per program). create_task schedules a coroutine concurrently and returns a Task. gather runs several awaitables concurrently and waits for them all, collecting the results.
In detail:
import asyncio
async def task(n):
await asyncio.sleep(n)
return n
async def main():
# 1) create_task — start immediately, can await later
t1 = asyncio.create_task(task(2))
t2 = asyncio.create_task(task(1))
await t1; await t2 # both were already running concurrently -> ~2s
# 2) gather — concurrent + collect results (in argument order)
results = await asyncio.gather(task(2), task(1)) # [2, 1]
# 3) gather with error handling
res = await asyncio.gather(task(1), task(2), return_exceptions=True)
asyncio.run(main()) # 4) entry point
gather(return_exceptions=False)(the default) — the first exception propagates to the top; the remaining tasks keep running (but their results are lost).- In 3.11+ there's
asyncio.TaskGroup— the modern replacement forgatherwith structured concurrency (auto-cancellation on error):
async with asyncio.TaskGroup() as tg:
tg.create_task(task(1))
tg.create_task(task(2))
# on exiting the block it waits for all; on error it cancels the rest
⚠️ Gotcha: you can't call asyncio.run() inside an already running loop (e.g. in Jupyter) — you'll get RuntimeError: asyncio.run() cannot be called from a running event loop.
83How does the event loop work internally?
senior
Short answer: It's an infinite loop on a single thread: it picks up tasks ready to run (callbacks), executes them until the next await, polls the OS for I/O readiness (selector.select), and schedules timers. Multitasking is cooperative — a task yields on its own at await.
In detail:
Simplified logic of a single loop iteration:
while running:
1. now = time()
2. run expired timers (call_later/sleep)
3. timeout = time until the nearest timer
4. events = selector.select(timeout) # epoll/kqueue: wait for I/O readiness
5. for each ready event -> schedule its callback
6. run all ready callbacks (one by one, up to their next await)
The key points:
- Single-threaded: at any given moment exactly one coroutine is running → within the stretch between
awaits there are no races, and locks inside the loop are often unnecessary. - Cooperative: the loop can't interrupt a coroutine — it waits until the coroutine reaches an
awaiton its own. If the coroutine never gets there, the loop stalls.
💡 Loop implementations: the standard one is pure Python; uvloop is a Cython implementation on top of libuv, 2–4x faster.
⚠️ Gotcha: long purely-computational code between awaits blocks the whole loop — all other connections "freeze."
84What happens if you call a blocking function in a coroutine, and how do you fix it?
senior
Short answer: A blocking call (requests.get, time.sleep, a heavy CPU loop) occupies the loop's single thread and doesn't yield control → all coroutines freeze. The fix is to offload the blocking code to a thread/process pool via loop.run_in_executor or asyncio.to_thread.
In detail:
import asyncio, time, requests
# ❌ BAD: blocks the entire loop for 5 seconds
async def bad():
time.sleep(5) # synchronous sleep — does NOT yield the loop
requests.get("https://example.com") # blocking HTTP
# ✅ GOOD
async def good():
await asyncio.sleep(5) # asynchronous sleep
# run the blocking library in a thread:
data = await asyncio.to_thread(requests.get, "https://example.com") # 3.9+
# or the old way:
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, requests.get, "https://example.com")
asyncio.to_thread(fn, *args)— runs a blocking function in aThreadPoolExecutorwithout blocking the loop. For I/O-bound blocking libraries.run_in_executor(executor, fn, ...)— the same thing; you can pass aProcessPoolExecutorfor CPU-bound work.
⚠️ Gotcha: the most common production pain is someone slipping a synchronous ORM/requests/time.sleep into an async endpoint, sending the latency of the whole service through the roof. Use async drivers (aiohttp, asyncpg) or to_thread.
85Why is asyncio useless for CPU-bound tasks?
senior
Short answer: asyncio is single-threaded. It only switches tasks at await points, which I/O has. Pure computation contains no awaits, runs on one thread on one core — no concurrency, no speedup (but there is overhead).
In detail:
CPU-bound code doesn't "wait" — there's nothing to wait for, it computes. Between computations there are no natural yield points. Even if you sprinkle in await asyncio.sleep(0), that just adds switching overhead, and it's still one thread = one core.
# This will NOT get faster with asyncio:
async def crunch(n):
return sum(i*i for i in range(n)) # no await, pure CPU
# For CPU-bound work in an async application:
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, blocking_crunch, 10**7)
Comparison table of "what to speed things up with":
| Workload type | Right tool |
|---|---|
| CPU-bound (computation) | multiprocessing / ProcessPoolExecutor |
| I/O-bound, async libraries | asyncio |
| I/O-bound, blocking libraries | threading / ThreadPoolExecutor / to_thread |
💡 Mnemonic: asyncio speeds up waiting, not computing.
⚠️ Gotcha: don't "wrap" a CPU function in async def hoping for a speedup — that only masks synchronous code.
86What are async generators, async context managers, and async for?
middle
Short answer: They're the asynchronous versions of iterators and context managers: async def + yield creates an async generator you can iterate with async for; __aenter__/__aexit__ give you async with.
In detail:
import asyncio
# async generator: yield + await inside
async def fetch_pages(urls):
for url in urls:
await asyncio.sleep(0.1) # simulating a request
yield f"data {url}"
async def main():
async for page in fetch_pages(["a", "b", "c"]):
print(page)
# async comprehension
pages = [p async for p in fetch_pages(["x", "y"])]
asyncio.run(main())
An async context manager — for example, an asynchronous connection to a database/an HTTP session:
class AsyncResource:
async def __aenter__(self):
await asyncio.sleep(0.1) # asynchronous open
return self
async def __aexit__(self, exc_type, exc, tb):
await asyncio.sleep(0.1) # asynchronous close
async def use():
async with AsyncResource() as r: # await on entry and exit
...
async for uses the __aiter__/__anext__ protocol (with StopAsyncIteration). async with uses __aenter__/__aexit__.
⚠️ Gotcha: you can't use async for/async with outside an async def. And a regular for won't work over an async generator.
87Why do we need asyncio.Lock/Semaphore/Queue if the loop is single-threaded?
middle
Short answer: Because between two awaits another coroutine can cut in and change shared state. asyncio.Lock protects a critical section that spans an await. Semaphore limits concurrency, Queue provides thread-safe (more precisely, loop-safe) exchange.
In detail:
import asyncio
lock = asyncio.Lock()
balance = 100
async def withdraw(amount):
global balance
async with lock: # won't release until the block exits
if balance >= amount:
await asyncio.sleep(0.01) # a switch point inside the section!
balance -= amount
Without the lock, between checking balance >= amount and the debit another coroutine could "pass" the check → overdraft.
Semaphore — limit the number of concurrent operations (e.g. no more than 10 requests to an API):
sem = asyncio.Semaphore(10)
async def fetch(url):
async with sem: # at most 10 at a time
return await http_get(url)
asyncio.Queue — producer/consumer without locks:
queue = asyncio.Queue(maxsize=100)
async def producer():
await queue.put(item)
async def consumer():
item = await queue.get(); queue.task_done()
⚠️ Gotcha: don't confuse asyncio.Lock with threading.Lock — they are NOT interchangeable. threading.Lock in a coroutine will block the whole loop; asyncio.Lock can't be used across threads.
89Why can't you write await in a regular function, and how do you "glue" sync and async together?
middle
Short answer: await is only allowed inside async def — otherwise it's a syntax error. You enter async from synchronous code via asyncio.run(); you call blocking sync from async via run_in_executor/to_thread.
In detail:
# ❌ SyntaxError: 'await' outside async function
def sync_fn():
await something()
# ✅ From sync to async — the entry point:
def main():
result = asyncio.run(async_fn()) # blocks the thread, drives the loop
# ✅ From async to blocking sync:
async def async_fn():
await asyncio.to_thread(blocking_io)
"Function coloring": async "infects" the calling code — to call an async function you must either be in an async context or start a loop. You can't "just call" a coroutine from regular code and get the result — you'll get a coroutine object.
⚠️ Gotcha: you can't run asyncio.run() from an already running loop. To mix things in Jupyter / in nested loops people use nest_asyncio (a hack) or refactor. You also can't use loop.run_until_complete() inside a coroutine.
90What are gevent and greenlet?
junior
Short answer: greenlet is a "green threads" library (lightweight coroutines with manual switching). gevent is a framework on top of greenlet that, through monkey-patching, makes blocking libraries non-blocking, implementing async without async/await.
In detail:
import gevent
from gevent import monkey
monkey.patch_all() # replaces socket, time.sleep, etc. with non-blocking versions
import requests # now requests is transparently "async"
def fetch(url):
return requests.get(url).status_code
jobs = [gevent.spawn(fetch, u) for u in urls]
gevent.joinall(jobs)
Differences from asyncio:
- gevent uses implicit cooperation: switching is hidden inside the patched functions, and ordinary synchronous code "magically" becomes concurrent. Pro — you don't need to rewrite anything as
async; con — the implicitness makes it harder to debug. - asyncio is explicit: the switch points are visible as
await.
💡 Before asyncio appeared (Python 3.4/3.5), gevent was the main way to do high-load I/O. These days new projects usually go with asyncio, but gevent is still widely used in legacy (gunicorn with gevent workers).
91How do you choose between threading, multiprocessing, and asyncio?
senior
Short answer: CPU-bound → multiprocessing. I/O-bound with thousands of connections and async libraries → asyncio. I/O-bound with blocking libraries or moderate concurrency → threading.
In detail — a decision tree:
Is the task CPU-bound (computation, hashing, ML, image processing)?
├── YES -> multiprocessing / ProcessPoolExecutor (bypass the GIL, parallelism across cores)
└── NO (I/O-bound: network, disk, database)
├── Are there async libraries (aiohttp, asyncpg) and/or do you need thousands of connections?
│ └── asyncio (single thread, cheap, scalable)
└── Only blocking libraries (requests, sync drivers) and hundreds of connections?
└── threading / ThreadPoolExecutor (threads release the GIL on I/O)
Summary table:
| Criterion | threading | multiprocessing | asyncio |
|---|---|---|---|
| CPU parallelism | ❌ (GIL) | ✅ | ❌ |
| Good for | I/O-bound, blocking libs | CPU-bound | I/O-bound, many connections |
| Memory per unit | KB–MB (stack) | tens of MB (process) | KB (coroutine) |
| Scale | hundreds of threads | ~ number of cores | tens/hundreds of thousands |
| Switching | preemptive (OS) | preemptive (OS) | cooperative (at await) |
| Races | yes (locks needed) | no (isolation) | yes (between await) |
| Data sharing | shared memory + locks | pickle/IPC/shm | shared memory (1 thread) |
💡 Hybrids are real: asyncio + ProcessPoolExecutor for the CPU parts; multiple processes, each with an event loop (uvicorn --workers N).
92What do threads, processes, and coroutines cost?
middle
Short answer: Coroutines are the cheapest (switching in user space, ~KB of memory). Threads are more expensive (switching through the OS kernel, KB–MB stacks). Processes are the most expensive (a separate address space, IPC, startup in milliseconds).
In detail:
| Startup | Memory | Switching cost | How many is reasonable | |
|---|---|---|---|---|
| Coroutine | ~µs | ~KB | a function call (user space) | 10⁴–10⁶ |
| Thread | ~tens of µs | KB–MB (stack) | kernel context switch (~µs) | 10²–10³ |
| Process | ms–tens of ms | MB | context switch + TLB flush | ~ number of cores |
Why it matters:
- A thread context switch = a trip into the kernel, saving registers, possibly flushing caches. Across thousands of threads this overhead adds up noticeably.
- A coroutine switches within a single thread — it's just saving/restoring a Python frame, with no kernel involvement.
- A process on
spawnre-imports modules; onforkit uses copy-on-write (cheaper, but pages are copied on write).
💡 That's why at 10,000 connections asyncio beats "thread per connection" by an order of magnitude in memory and switching, while at the same time NOT speeding up computation itself.
⚠️ Gotcha: too many threads/processes = degradation due to switching and resource contention. Pool size is tuned: processes ≈ number of cores, threads for I/O — empirically (tens to hundreds).
931. Why do we need type hints if Python is dynamic?
concept
Short answer: Type annotations are not checked by the interpreter at runtime — they exist for static analysis (mypy, pyright), IDE autocompletion, documentation, and catching errors early, before the code runs. Python stays dynamic; hints are a "contract" that external tools verify.
In detail:
Type hints (PEP 484) don't affect program execution. The interpreter ignores them — you can pass a string where an int is expected, and the code won't fail on its own.
def add(a: int, b: int) -> int:
return a + b
# Python does NOT check types — this runs without errors:
print(add("a", "b")) # "ab" — string concatenation!
# But mypy reports an error BEFORE running:
# error: Argument 1 to "add" has incompatible type "str"; expected "int"
So why are they useful:
- Early error detection —
mypy/pyrightcatch type mismatches before production. - Autocompletion and navigation in the IDE (PyCharm, VS Code).
- Documentation — the signature
def f(x: list[int]) -> dict[str, int]is clearer than a comment. - Refactoring — when a type changes, the tool shows every place it's used.
- Contracts in large teams — they reduce "implicit" agreements.
# Without hints — it's unclear what's expected:
def process(data, config): ...
# With hints — self-documenting:
def process(data: list[dict[str, int]], config: "Config") -> bool: ...
⚠️ Gotcha: Annotations are not validation. def f(x: int) won't prevent passing a string at runtime. If you actually need to validate incoming values, use pydantic / explicit assert / manual checks. Hints != runtime validation.
💡 Interview formula: "Type hints are a tool for static analysis and readability; Python stays duck-typed and dynamic, and the checking is done by mypy/the IDE, not the interpreter itself."
942. Optional, Union, and the `|` syntax
middle
Short answer: Optional[X] is Union[X, None], i.e. "X or None". Union[A, B] means "either A or B". In Python 3.10+ there's the shorthand syntax X | None and A | B.
In detail:
from typing import Optional, Union
# Old syntax
def find_user(uid: int) -> Optional[str]: # str | None
return None
def parse(x: Union[int, str]) -> int: # int or str as input
return int(x)
# Python 3.10+ — the | operator (PEP 604)
def find_user2(uid: int) -> str | None: ...
def parse2(x: int | str) -> int: ...
Optional[X] does NOT mean "the argument is optional" — it means the value may be None. Optionality is set by a default value.
# Optional is about the type, not about being optional:
def f(x: Optional[int]) -> None: ... # x is REQUIRED, but may be None
f() # error — argument not passed
f(None) # ok
f(5) # ok
# An optional argument:
def g(x: int | None = None) -> None: ... # the typical pattern
Type narrowing — mypy understands None checks:
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger"
return f"Hello, {name}" # here name is already str, .upper() etc. are available
⚠️ Gotcha: A mutable default def f(items: list[int] = []) is a shared list across all calls (the classic bug). Use def f(items: list[int] | None = None) and inside items = items or [].
⚠️ Gotcha: Don't confuse Optional[X] with "optionality" — it's about whether None is allowed, not about whether the argument is present.
953. `List`/`Dict` vs `list`/`dict`, `Any`
middle
Short answer: Before Python 3.9 annotations required typing.List, typing.Dict. From 3.9+ you can use the built-in list[int], dict[str, int] directly (PEP 585). Any turns off type checking — "everything is allowed".
In detail:
# Old style (Python < 3.9) — import from typing
from typing import List, Dict, Tuple, Set
def f(x: List[int], y: Dict[str, int]) -> Tuple[int, ...]: ...
# Modern (Python 3.9+) — generic built-in types
def f(x: list[int], y: dict[str, int]) -> tuple[int, ...]: ...
These days typing.List and friends are marked as deprecated — the built-in syntax is preferred.
Any is an "escape hatch": the value is compatible with any type in both directions, and checks are disabled.
from typing import Any
def parse(data: Any) -> Any:
return data.whatever() # mypy does NOT complain — Any "swallows" everything
x: Any = get_value()
x.foo().bar[0] # no type errors
Any vs object:
Any— disables checks; you can call any method.object— the base type of everything, but you can do almost nothing withoutcast/a check (type-safe).
def f(x: object) -> None:
x.upper() # mypy: error — object has no .upper()
if isinstance(x, str):
x.upper() # ok after narrowing
⚠️ Gotcha: Any is contagious — it "leaks" through expressions and silently disables checking across a large part of the code. Use it surgically; for "unknown but safe" prefer object + isinstance.
964. `Callable`, `TypeVar`, `Generic`
senior
Short answer: Callable[[Args], Ret] describes a function/callable. TypeVar is a type variable for generic functions that preserves the relationship between input and output types. Generic[T] is the base class for creating generic classes.
In detail:
Callable:
from typing import Callable
# a function taking int and str, returning bool
handler: Callable[[int, str], bool]
def apply(fn: Callable[[int], int], value: int) -> int:
return fn(value)
apply(lambda x: x * 2, 5) # 10
# any arguments:
cb: Callable[..., None]
TypeVar — links types so they "flow" through a function:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: # element type = result type
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: str
# Constrained TypeVar (only these types):
Num = TypeVar("Num", int, float)
def double(x: Num) -> Num:
return x * 2
# Bound — subtypes:
from numbers import Number
N = TypeVar("N", bound=Number)
Generic — generic classes:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
v = s.pop() # v: int
In Python 3.12+ there's a new syntax (PEP 695) without an explicit TypeVar:
def first[T](items: list[T]) -> T: # 3.12+
return items[0]
class Stack[T]: # 3.12+
...
⚠️ Gotcha: A single TypeVar in a signature links all of its occurrences — def f(a: T, b: T) -> T requires a and b to be the same type. If you need them independent — declare two TypeVars.
975. `Protocol` — structural typing
senior
Short answer: Protocol (PEP 544) defines "duck typing" statically: a class qualifies if it has the required methods/attributes, without explicit inheritance. It's the formalization of duck typing for mypy.
In detail:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle: # does NOT inherit Drawable explicitly
def draw(self) -> str:
return "○"
class Square:
def draw(self) -> str:
return "□"
def render(shape: Drawable) -> None: # accepts anything that can draw()
print(shape.draw())
render(Circle()) # ok — structurally compatible
render(Square()) # ok
This differs from inheritance (nominal typing): the object doesn't need to know about the protocol. Handy for "interfaces" of third-party classes.
runtime_checkable — allows isinstance with a protocol (checks only the presence of methods, not signatures):
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
isinstance([1, 2, 3], Sized) # True
⚠️ Gotcha: runtime_checkable only checks for the presence of attributes with the right names, but NOT their signatures or types. isinstance(obj, MyProto) may return True for an object that's incompatible by signature.
💡 Protocol is about "if it looks like a duck". Use it when you can't/don't want to change the class hierarchy but want to statically guarantee the presence of methods.
986. `Literal`, `TypedDict`, `Final`, `cast`
senior
Short answer: Literal — specific values as a type (Literal["GET", "POST"]). TypedDict — a dict with typed keys. Final — a constant that can't be reassigned. cast — a hint to mypy "trust me, this is type X" without a runtime check.
In detail:
Literal — restricts values:
from typing import Literal
def request(method: Literal["GET", "POST", "PUT"]) -> None: ...
request("GET") # ok
request("FETCH") # mypy: error — not an allowed value
Mode = Literal["r", "w", "a"]
TypedDict — the structure of a dict:
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
u: User = {"id": 1, "name": "Ann", "email": "a@x.io"}
u["id"] # mypy knows this is int
# Optional keys:
class Config(TypedDict, total=False):
timeout: int # may be absent
Final — forbids reassignment (mypy checks it):
from typing import Final
MAX_SIZE: Final = 100
MAX_SIZE = 200 # mypy: error — Cannot assign to final name
class C:
PI: Final[float] = 3.14
cast — overriding a type for mypy (without a runtime check):
from typing import cast
data = get_json() # type: Any
user = cast(User, data) # tell mypy: treat this as User
# At runtime cast does NOTHING — it just returns the value
⚠️ Gotcha: cast does not check the value at runtime — it's just a hint for the static analyzer. If the object isn't actually that type, you'll get an error later, in an unexpected place. Don't overuse it — it's a way to "fool" mypy.
⚠️ Gotcha: TypedDict is still a plain dict at runtime — there's no value validation (unlike pydantic).
997. typing vs runtime — do annotations affect execution?
middle
Short answer: In ordinary code annotations don't affect execution — they're stored in __annotations__ and aren't checked. But some tools (pydantic, dataclasses, FastAPI) read annotations at runtime and use them to generate logic/validation.
In detail:
def f(x: int) -> str:
return str(x)
print(f.__annotations__) # {'x': <class 'int'>, 'return': <class 'str'>}
# The interpreter stores them, but doesn't check them
Annotations become "strings" with from __future__ import annotations (PEP 563) — lazy evaluation, which helps with forward references and circular imports:
from __future__ import annotations
class Node:
def __init__(self, next: Node | None = None): # Node isn't defined yet — ok,
self.next = next # the annotation is stored as a string
Who reads annotations at runtime:
- dataclasses — generate
__init__from annotated fields. - pydantic — validates values against types.
- FastAPI — builds request parsing/validation.
- typing.get_type_hints() — resolves annotations (including string ones).
from dataclasses import dataclass
@dataclass
class Point:
x: int # the annotation is REQUIRED — dataclass won't create a field without it
y: int
# Here annotations really do affect the generated __init__
⚠️ Gotcha: A dataclass attribute without an annotation does NOT become a field — z = 0 (without z: int = 0) is just a class attribute. dataclass looks specifically at __annotations__.
⚠️ Gotcha: With from __future__ import annotations annotations become strings — obj.__annotations__["x"] returns "int", not the class. To resolve them, use typing.get_type_hints().
1008. `@dataclass`: field, frozen, default_factory, `__post_init__`
middle
Short answer: @dataclass auto-generates __init__, __repr__, __eq__ from annotated fields. field() configures a field (e.g. default_factory for mutable defaults). frozen=True makes the instance immutable. __post_init__ is a hook that runs after the auto-generated __init__.
In detail:
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
y: int = 0 # default
p = Point(1, 2)
print(p) # Point(x=1, y=2) -- auto-repr
print(p == Point(1, 2)) # True -- auto-eq
default_factory — for mutable defaults (lists, dicts):
@dataclass
class Cart:
items: list[str] = field(default_factory=list) # NOT list = []
a, b = Cart(), Cart()
a.items.append("x")
print(b.items) # [] — each has its own list
frozen — immutability (can be used as a dict key / in a set):
@dataclass(frozen=True)
class Coord:
lat: float
lon: float
c = Coord(1.0, 2.0)
c.lat = 5.0 # FrozenInstanceError
{c} # hashable — can go in a set
__post_init__ — validation/extra initialization:
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False) # not in __init__, we compute it ourselves
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError("width must be positive")
self.area = self.width * self.height
Useful parameters: @dataclass(frozen=True, slots=True, kw_only=True, order=True).
⚠️ Gotcha: field(default_factory=list) is mandatory for mutable defaults — items: list = [] raises ValueError (dataclass forbids it at definition time).
⚠️ Gotcha: frozen=True forbids assignment even inside __post_init__. To set a computed field in a frozen class, use object.__setattr__(self, "area", ...).
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.