Q: What is the difference between mutable and immutable types?
Immutable objects cannot be changed after creation. Mutating them creates a new object.
| Immutable | Mutable |
|---|---|
int, float, complex |
list, dict, set |
str, bytes |
bytearray |
tuple, frozenset |
custom objects (usually) |
# Immutable — reassignment creates a new object
a = 'hello'
id_before = id(a)
a += ' world'
id(a) == id_before # False — new object
# Mutable — modified in place
lst = [1, 2, 3]
id_before = id(lst)
lst.append(4)
id(lst) == id_before # True — same objectWhy it matters: Immutable objects are hashable (can be dict keys / set members). Mutable default arguments cause a classic bug:
# BUG — default list is shared across all calls
def append_to(item, lst=[]):
lst.append(item)
return lst
# FIX
def append_to(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lstQ: What is the difference between == and is?
==compares value (calls__eq__)iscompares identity (same object in memory)
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — same values
a is b # False — different objects
# is used correctly:
x is None # check for None (not x == None)
x is True # check for singleton TrueQ: What is Python's GIL?
The Global Interpreter Lock ensures only one thread executes Python bytecode at a time, even on multi-core machines.
- Released during I/O operations →
threadingstill helps for I/O-bound tasks - Never released for CPU work → use
multiprocessingfor CPU-bound parallelism - Not an issue for
asyncio(single-threaded by design)
Q: Explain Python's scoping rules.
Python resolves names in this order: Local → Enclosing → Global → Built-in.
x = 'global'
def outer():
x = 'enclosing'
def inner():
x = 'local'
print(x) # 'local'
inner()
print(x) # 'enclosing'
outer()
print(x) # 'global'
# global / nonlocal — modify outer scope
def counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return incQ: How does Python manage memory?
- Reference counting — each object tracks how many references point to it; freed when count hits 0.
- Cycle collector — handles circular references that reference counting misses.
- Memory pools (
pymalloc) — small objects (< 512 bytes) are allocated from pre-allocated pools for speed.
import sys
x = [1, 2, 3]
sys.getrefcount(x) # always at least 1 (the temporary arg adds 1 too)Q: What is a shallow copy vs deep copy?
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # or list(original) / original[:]
deep = copy.deepcopy(original)
original[0].append(99)
print(shallow[0]) # [1, 2, 99] — inner list shared
print(deep[0]) # [1, 2] — independent copyQ: What is the difference between an iterable and an iterator?
- Iterable: has
__iter__()returning an iterator (lists, tuples, dicts, generators). - Iterator: has both
__iter__()and__next__(); remembers position; raisesStopIterationwhen exhausted.
lst = [1, 2, 3] # iterable
it = iter(lst) # iterator
next(it) # 1
next(it) # 2Q: What are the advantages of generators?
- Memory efficient — produce values one at a time, never build the full list.
- Lazy evaluation — compute only what is consumed.
- Infinite sequences — can represent streams with no end.
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
gen = fib()
[next(gen) for _ in range(8)] # [0, 1, 1, 2, 3, 5, 8, 13]Q: What is a decorator? How do you preserve metadata?
A decorator is a callable that takes a function and returns a replacement function, adding behaviour before/after the original.
import functools
def timer(func):
@functools.wraps(func) # copies __name__, __doc__, __module__
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs)
print(f'{func.__name__} took {time.perf_counter()-start:.4f}s')
return result
return wrapper
@timer
def slow():
"""Sleeps briefly."""
import time; time.sleep(0.1)
slow.__name__ # 'slow' (not 'wrapper', thanks to @wraps)
slow.__doc__ # 'Sleeps briefly.'| Gotcha | Explanation |
|---|---|
| Mutable default argument | Default evaluated once at definition time |
| Late-binding closure | Loop variable captured by reference, not value |
x += 1 in nested scope |
Creates local, shadows outer — use nonlocal |
== vs is for None |
Always use is None, never == None |
| Modifying a list while iterating | Iterate over a copy: for x in lst[:] |
dict.keys() returns a view |
Convert to list() if you need a snapshot |
# Late-binding closure — classic bug
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — all see i=2
# Fix — capture at definition time
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]