Python Programming Questions
Python as an interview language: core syntax, data types and built-in collections, comprehensions, iterators and generators, idiomatic style, and the standard library, extending into data-oriented and automation use of the language and its common libraries. Covers writing correct, Pythonic code and reasoning about the language's semantics. The most heavily exercised language surface in this category across engineering and data roles.
Explain Python's LEGB scope resolution (Local, Enclosing, Global, Built-in). Write a nested function example that shows when you need the nonlocal keyword to modify an enclosing variable.
Sample Answer
Direct answer
Python resolves a name by searching four scopes in order: Local (the current function body), Enclosing (any outer function that defines this one, for nested functions), Global (the module), Built-in (names like len and range). Lookup stops at the first scope that has the name. The catch is assignment, not lookup: if a name is assigned anywhere inside a function body, Python treats it as local to that function for the whole body, even before the assignment line runs, unless you declare it nonlocal (binds to the nearest enclosing function scope) or global (binds to module scope).
Structured elaboration
The four scopes, outside in:
- Local: parameters and names assigned inside the current function.
- Enclosing: names in any outer function that lexically contains this one (relevant only for nested functions); this is what makes closures possible.
- Global: names bound at module level.
- Built-in: the
builtinsmodule (len,range,print, and so on), checked last.
Why nonlocal exists: Python decides, at compile time, whether a name inside a function is local by scanning the function body for any assignment to that name. Reading an enclosing variable works fine without any keyword. The moment you assign to a name that lives in an enclosing scope, Python's default is to create a brand-new local variable with that name instead of reaching outward, which shadows the outer one and usually breaks the intended logic. nonlocal tells the compiler: this name is not local, resolve it in the nearest enclosing function scope (skipping straight past to global is a compile error if no enclosing binding exists). This differs from global, which always targets the module scope regardless of nesting depth.
The closure "late binding" gotcha: a nested function does not capture a variable's value at definition time, it captures the variable itself and reads it whenever the nested function actually runs. If several closures share a loop variable, they all read whatever value that variable holds when they are finally called, which is usually the loop's last value.
Worked example
Where nonlocal is required, verified on CPython 3.12:
def make_accumulator():
total = 0
def add(x):
nonlocal total # without this, `total += x` raises UnboundLocalError
total += x
return total
return add
acc = make_accumulator()
print(acc(10)) # 10
print(acc(5)) # 15
print(acc(1)) # 16
What happens if you drop nonlocal (exact CPython 3.12 message):
def make_broken_accumulator():
total = 0
def add(x):
total += x # `total` is assigned here, so LEGB makes it local to add()
return total
return add
broken = make_broken_accumulator()
try:
broken(10)
except UnboundLocalError as e:
# expected: total is assigned inside add(), so LEGB makes it local for the
# whole function body, and the read on the += happens before any assignment runs
print(f'raises as expected: UnboundLocalError: {e}')
The late-binding closure gotcha, same mechanism (enclosing-scope lookup happens at call time, not definition time):
funcs = []
for i in range(4):
funcs.append(lambda: i)
print([f() for f in funcs]) # [3, 3, 3, 3] -- all read the final value of i
# fix: bind the current value as a default argument, evaluated at definition time
funcs2 = []
for i in range(4):
funcs2.append(lambda i=i: i)
print([f() for f in funcs2]) # [0, 1, 2, 3]
Trade-offs & pitfalls
- Reaching for
nonlocalon every enclosing read is a common overcorrection: it is only needed when you assign to the name, never for reading it. nonlocalcannot create a new binding; if no enclosing function scope already has that name, Python raises aSyntaxErrorat compile time, it will not silently fall through to global.- The late-binding gotcha bites hardest with
lambdainside loops (event handlers, callbacks passed to a scheduler, list comprehensions of functions); the default-argument fix works because default values are evaluated once, at function-definition time, not at call time. - Mutable enclosing state (a list or dict) sidesteps
nonlocalentirely, since mutating the object's contents is not the same as rebinding the name, but that trades an explicit rebind for a shared mutable object, which is its own source of bugs in concurrent code.
Python's sort is stable and adaptive (Timsort). What does stability actually buy you when you need to sort by more than one key, and what's the difference between list.sort() and the builtin sorted() in terms of what they return and when you'd choose one over the other?
Sample Answer
Direct answer
Stability means elements that compare equal on the sort key keep their original relative order, and that is what makes multi-key sorting composable: sort by the least-significant key first, then by the most-significant key, and ties on the final sort automatically fall back to the order from the earlier pass. list.sort() sorts a list in place and returns None; sorted() returns a new list (and accepts any iterable, not just a list) leaving the original untouched. Use list.sort() when you own the list and no longer need the original order; use sorted() whenever you need to preserve the input or you are sorting something that is not already a list.
Structured elaboration
Why Timsort's stability matters for multi-key sorts. Timsort finds existing ascending/descending runs in the data, merges them with a stable merge (equal-key elements from different runs are never reordered relative to each other), and that stability is a load-bearing property, not an incidental detail: it is what lets you build an n-key sort out of n single-key sorts instead of writing one comparator that juggles all keys at once.
- Multi-pass, relying on stability: sort by the least significant key first; each subsequent sort only needs to break ties left over from the previous pass, because the previous pass's relative order survives for anything the current pass considers equal.
- Composite key, single pass:
sort(key=lambda r: (key1, key2, ...))achieves the same result in one call and is usually simpler when all the keys are known up front; the multi-pass version is useful when the keys or the number of passes are decided dynamically, or when different passes need different comparators (e.g. one ascending, one descending, via separatereverse=flags rather than negating values).
list.sort() vs. sorted():
list.sort() | sorted(iterable) | |
|---|---|---|
| Mutates input? | Yes, in place | No, leaves input untouched |
| Return value | None | A new list |
| Works on | list only | Any iterable (list, tuple, dict keys, generator, ...) |
| When to choose | You own the list, don't need the old order, want to avoid an extra allocation | You need to keep the original, or the input isn't a list at all |
Returning None from list.sort() is a deliberate API choice (shared with other in-place mutators like list.append) to make it visually obvious at the call site that the operation is a mutation, not a value-producing expression; x = some_list.sort() is a very common and very wrong pattern, since x ends up None.
Worked example
records = [
{"user": "A", "time": "2023-01-02", "score": 90},
{"user": "A", "time": "2023-01-01", "score": 90},
{"user": "B", "time": "2023-01-01", "score": 95},
]
r1 = list(records)
r1.sort(key=lambda r: r["time"]) # pass 1: least significant key
r1.sort(key=lambda r: r["score"], reverse=True) # pass 2: most significant key
print(r1)
r2 = sorted(records, key=lambda r: (-r["score"], r["time"])) # single composite-key pass
print(r2)
assert r1 == r2
a = [3, 1, 2]
print(a.sort(), a) # None [1, 2, 3]
b = (3, 1, 2)
print(sorted(b), b) # [1, 2, 3] (3, 1, 2) -- tuple unchanged, sorted() works on it anyway
Both r1 and r2 come out identical: [B/2023-01-01/95, A/2023-01-01/90, A/2023-01-02/90]. Because the two A records tie on score, stability (in the multi-pass version) and the tuple's second element (in the composite-key version) both resolve that tie the same way, in favor of the earlier time.
Trade-offs & pitfalls
- Multi-pass only works because each pass is itself stable. If you ever swap in a sort that isn't stable partway through a multi-pass chain, the whole composability argument breaks; this is specific to Python's guarantee that both
list.sort()andsorted()are always stable, not a property you get from sorting in general. - Composite-key tuples need consistent direction per field.
(-r["score"], r["time"])negates score to get descending order while keeping time ascending; this trick only works cleanly for numeric keys. For a descending string key, or when combining ascending and descending on non-numeric fields, the multi-pass approach with separatereverse=flags is clearer than trying to invert a string key. - Common wrong turn: calling
sorted(some_list.sort())(chaining the two), which sortssome_listin place, gets backNone, and then crashes trying to callsorted(None)..sort()andsorted()solve the same underlying problem but are never meant to be composed with each other. sorted()on a large iterable still has to materialize it into a list internally; for a truly enormous or infinite stream, sorting isn't the right operation at all, you'd want an external/streaming approach (e.g. a k-way merge over pre-sorted chunks) instead of pulling everything into memory first.
Given a NumPy array a of shape (N, M), write Python code to compute a boolean mask of rows that contain any NaN using vectorized NumPy operations. Do not use Python loops.
Sample Answer
Vectorized mask for rows containing any NaN:
import numpy as np
# a is shape (N, M)
mask = np.isnan(a).any(axis=1)
Explanation:
- np.isnan(a) produces boolean array shape (N, M).
- .any(axis=1) reduces per row to True if any element is NaN. A numpy axis names which direction a reduction collapses across for a 2D array: axis=0 collapses DOWN each column (one result per column), axis=1 collapses ACROSS each row (one result per row); since the question asks for a per-row answer, one True/False per row of the (N, M) array, axis=1 is the one that produces exactly N results, matching the number of rows.
Worked example, verified on CPython 3.12:
a = np.array([[1.0, 2.0], [np.nan, 4.0], [5.0, 6.0]])
print(np.isnan(a))
# [[False False]
# [ True False]
# [False False]]
print(np.isnan(a).any(axis=1))
# [False True False]
Row 0 ([1.0, 2.0]) has no NaN, so it reduces to False; row 1 ([nan, 4.0]) has one, so it reduces to True; row 2 ([5.0, 6.0]) has none, so False again, exactly the boolean mask a caller would use to filter out or flag the bad row.
This is fully vectorized, memory-efficient (single boolean array) and avoids Python loops. For very large arrays consider processing in chunks to limit peak memory.
Implement a memoize decorator that caches a function's return value keyed by its arguments, supports both positional and keyword arguments, and preserves the wrapped function's name and docstring. Why does functools.wraps matter here?
Sample Answer
Approach
Wrap the function with a cache dict keyed by a canonical representation of its call arguments; build that key from the positional args tuple plus a sorted tuple of the kwargs items (sorting removes the effect of keyword order, so calling with the same keywords in a different order still hits the same cache entry), and use functools.wraps so the decorated function still looks like the original one to any code that inspects it.
Code (Python 3.12)
import functools
def memoize(func):
"""Cache func's return value, keyed by its (args, kwargs).
Requires all arguments to be hashable.
"""
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
wrapper.cache_clear = cache.clear
return wrapper
calls = {"n": 0}
@memoize
def slow_add(a, b=0):
"""Add two numbers."""
calls["n"] += 1
return a + b
print(slow_add(2, b=3))
# 5
print(slow_add(2, b=3))
# 5
print(calls["n"])
# 1 -- the second call was served entirely from cache
print(slow_add.__name__, slow_add.__doc__)
# slow_add Add two numbers.
Why functools.wraps matters here
Without it, wrapper (a plain closure) is the object actually bound to the decorated name, and it carries wrapper's own __name__, __doc__, and __module__, not the original function's:
def memoize_no_wraps(func):
cache = {}
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
@memoize_no_wraps
def slow_add(a, b=0):
"""Add two numbers."""
return a + b
print(slow_add.__name__, slow_add.__doc__)
# wrapper None
Every function decorated with memoize_no_wraps would report its name as "wrapper" and lose its docstring entirely, which breaks anything that introspects the function by name (logging that prints func.__name__, documentation generators that read __doc__, debuggers and stack traces showing the wrong name, or pickle, which needs a function's real qualified name to serialize a reference to it). functools.wraps(func) copies __name__, __doc__, __module__, __qualname__, and __dict__ from func onto wrapper, so the decorated function is indistinguishable from the original one under introspection, even though the actual object called at runtime is still wrapper.
Key points
- Sorting
kwargs.items()before putting them in the key meansslow_add(2, b=3)and (a hypothetical)slow_add(b=3, a=2)collide to the same cache entry, since dict key order at the call site shouldn't change what gets cached. - The cache dict itself never evicts anything; it grows for as long as the process runs and new distinct argument combinations show up, which is fine for a small, bounded input domain and a memory leak in anything else.
- Exposing
wrapper.cache_clear = cache.cleargives callers (and tests) a way to reset the cache without reaching into closure internals.
Complexity
Cached calls: O(1) average, one hash-table lookup keyed by (args, sorted(kwargs.items())). Uncached (first-time) calls: whatever func itself costs, plus that same O(1) lookup and insert. Space: O(u) where u is the number of distinct argument combinations seen so far, since every one of them gets its own permanent cache entry.
Edge cases
- Any unhashable argument (a
listor adictpassed positionally or by keyword) raisesTypeErrorwhen building the key, since(args, ...)itself is only hashable if every element inside it is; this surfaces as an ordinaryTypeError: unhashable typeat call time, not a silent cache miss. - Numeric arguments that are equal but different types (
slow_add(2, b=3)vs.slow_add(2.0, b=3.0)) share ONE cache entry, not two:(2,) == (2.0,)isTrueand the tuples hash equal, so the dict treats them as the same key and the second call is served from the cache. The same collision foldsTrue,1, and1.0together. That is usually what you want, but it is a subtle trap when the function's result actually depends on the argument's exact type. - An unbounded cache is the biggest production risk: for a function called with effectively unlimited distinct arguments (say, one call per unique user ID over a long-running process), this cache grows forever; the standard-library
functools.lru_cache(maxsize=...)solves exactly this by bounding the cache size and evicting least-recently-used entries, and is the better default choice once eviction matters, keeping this hand-rolled version useful mainly as a teaching example or for cases needing custom key logiclru_cachedoesn't support.
You have CPU-bound preprocessing that's become a bottleneck in a Python pipeline. Walk through your decision process: threading, multiprocessing, or asyncio, and why. Then say how your answer changes if the bottleneck were I/O-bound instead (say, many blocking network calls) and you needed to run them concurrently without a full rewrite.
Sample Answer
Direct answer
For CPU-bound preprocessing, reach for multiprocessing, not threading or asyncio. CPython threads are still limited by the GIL (Global Interpreter Lock, the mutex that lets only one thread execute Python bytecode at a time), so extra threads do not add parallel CPU throughput for pure-Python work. Multiple processes each get their own interpreter and GIL, so they genuinely run on separate cores. If the bottleneck were I/O-bound instead (many blocking network calls), the calculus flips: threading or asyncio both work because I/O releases the GIL while waiting, and if you cannot afford to rewrite the code as async, wrapping the existing blocking calls in a thread pool gets you concurrency without touching the call sites.
Structured elaboration
Decision framework
| Bottleneck | Best fit | Why | Rewrite cost |
|---|---|---|---|
| CPU-bound (pure Python loops, parsing, transforms) | multiprocessing | Bypasses the GIL, uses multiple cores | Moderate: must be picklable, watch memory duplication |
| CPU-bound, but hot path is numpy/C extension | Threads can help | Many numpy/BLAS (Basic Linear Algebra Subprogram) operations release the GIL internally during the C computation | Low |
| I/O-bound (network, disk, DB calls), full control of the code | asyncio | Cooperative concurrency, single thread, no GIL contention, scales to thousands of concurrent waits | High: every call in the chain must be async-compatible |
| I/O-bound, existing blocking/sync code you cannot fully rewrite | Thread pool (concurrent.futures.ThreadPoolExecutor) or asyncio.to_thread | Blocking I/O releases the GIL while waiting, so threads overlap waits even though only one runs Python bytecode at a time | Low: wrap existing calls, no async rewrite |
Why threading fails for CPU work but multiprocessing does not: the GIL only needs to be released while native code is running outside the interpreter loop, or while a thread is blocked in a system call. A tight Python loop doing arithmetic never leaves the interpreter, so the GIL is essentially held the whole time; other threads make no CPU progress. A separate process has its own interpreter and its own GIL, so N processes can use N cores concurrently.
Why asyncio does not help the CPU case: asyncio is single-threaded cooperative multitasking. It only reclaims time that would otherwise be spent idly waiting (on a socket, a file descriptor, a timer). A CPU-bound loop never yields control back to the event loop, so it blocks every other coroutine until it finishes; you get zero parallelism.
Migrating the I/O-bound half without a full rewrite: if you have synchronous code (e.g. calls to a blocking HTTP client or database driver) and cannot convert every layer to async def/await, you do not have to. Two low-effort options:
- Run the existing blocking calls in a thread pool via
concurrent.futures.ThreadPoolExecutor, and drive them concurrently with.submit()/as_completed(), noasynckeyword anywhere. - If you already have an asyncio event loop elsewhere in the program and want to call into old blocking code from it, use
asyncio.to_thread(blocking_fn, *args), which offloads the blocking call to a worker thread and awaits the result, letting the rest of your code stay synchronous.
Worked example
CPU-bound case, ProcessPoolExecutor applying a preprocessing function to large numpy arrays via a memory-mapped file so workers do not each need a private in-memory copy of the whole array (verified on CPython 3.12, seeded so the output is reproducible):
import numpy as np
from concurrent.futures import ProcessPoolExecutor, as_completed
import os
INPUT_PATH, OUTPUT_PATH = "input.dat", "output.dat"
DTYPE, SHAPE, CHUNK_SIZE = np.float32, (1_000_000,), 200_000
_worker_in = _worker_out = None
def _init_worker(input_path, output_path, shape, dtype):
global _worker_in, _worker_out
_worker_in = np.memmap(input_path, dtype=dtype, mode="r", shape=shape)
_worker_out = np.memmap(output_path, dtype=dtype, mode="r+", shape=shape)
def process_chunk(bounds):
start, end = bounds
_worker_out[start:end] = np.sqrt(_worker_in[start:end]) * 2.0
return bounds
def parallel_preprocess():
if not os.path.exists(INPUT_PATH):
mm = np.memmap(INPUT_PATH, dtype=DTYPE, mode="w+", shape=SHAPE)
mm[:] = np.random.default_rng(42).random(SHAPE[0]).astype(DTYPE)
mm.flush(); del mm
out = np.memmap(OUTPUT_PATH, dtype=DTYPE, mode="w+", shape=SHAPE)
out[:] = 0; out.flush(); del out
slices = [(i, min(i + CHUNK_SIZE, SHAPE[0])) for i in range(0, SHAPE[0], CHUNK_SIZE)]
with ProcessPoolExecutor(max_workers=os.cpu_count(), initializer=_init_worker,
initargs=(INPUT_PATH, OUTPUT_PATH, SHAPE, DTYPE)) as exe:
futures = [exe.submit(process_chunk, s) for s in slices]
for fut in as_completed(futures):
fut.result()
if __name__ == "__main__":
parallel_preprocess()
produced = np.memmap(OUTPUT_PATH, dtype=DTYPE, mode="r", shape=SHAPE)
source = np.memmap(INPUT_PATH, dtype=DTYPE, mode="r", shape=SHAPE)
expected = np.sqrt(source) * 2.0
matches = bool(np.allclose(produced, expected))
print(f"output.dat matches sqrt(input) * 2.0 for all {SHAPE[0]:,} elements: {matches}")
del produced, source, expected
os.remove(INPUT_PATH)
os.remove(OUTPUT_PATH)
Running this produces:
output.dat matches sqrt(input) * 2.0 for all 1,000,000 elements: True
a deterministic verification line instead of a raw wall-clock timing, since timing numbers vary by machine and would not reproduce for a reader running this elsewhere. The script computes sqrt(input) * 2.0 directly with numpy on the same seeded input, confirms every element the process pool actually wrote matches, then removes the memory-mapped files it created.
I/O-bound migration, without touching the existing blocking function:
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def blocking_call(url): # existing synchronous code, unchanged
time.sleep(0.01) # stand-in for a blocking network call
return f"result for {url}"
urls = [f"https://example.invalid/{i}" for i in range(20)]
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(blocking_call, u): u for u in urls}
for fut in as_completed(futures):
fut.result() # threads overlap the sleep/network-wait time
Trade-offs & pitfalls
- Multiprocessing's cost is process startup and inter-process communication (IPC): every argument and result crosses a pickle boundary, and naively passing large arrays duplicates memory per worker. Memory-mapped files or
multiprocessing.shared_memoryavoid the copy by letting workers map the same backing buffer instead of receiving a serialized copy. - Chunk size matters: too small, and scheduling/IPC overhead dominates; too large, and you lose load-balancing across workers. This has to be tuned per workload rather than assumed.
- Threads for the I/O case genuinely work, but they do not scale as far as
asynciofor very high fan-out (thousands of concurrent connections) because each thread carries OS-level stack and scheduling overhead that a coroutine does not. - A common wrong turn: reaching for
asyncio"because it's modern" on a CPU-bound bottleneck. It changes nothing about GIL contention and only adds complexity. - The thread-pool-around-blocking-code migration is a stopgap, not a long-term architecture: it still burns one OS thread per in-flight call, whereas a true
asynciorewrite trades that for a much larger number of lightweight coroutines. It is the right choice when a full rewrite is not affordable right now, not a permanent replacement for one.
Unlock Full Question Bank
Get access to all Python Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.