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.
Compare Python's three string-formatting styles: percent formatting, str.format, and f-strings. What are the readability, performance, and security (format-string injection) differences between them?
Sample Answer
Direct answer
F-strings (Python 3.6+) are the best default: they are the most readable because the expression sits inline at the point of use, and the fastest because the interpreter compiles the expression directly into the code rather than parsing a template string at run time. %-formatting is the oldest and leanest for simple positional substitution. str.format() is the most flexible for templates that are built or reused separately from the values (and the most dangerous if that template can come from untrusted input), but pays a parsing and lookup cost that makes it the slowest of the three in most cases.
Structured elaboration
Readability
- F-strings: the expression and its formatting spec live together, e.g.
f"loss={loss:.4f}"; nothing to visually match up against a separate argument list. str.format(): clear for reusable templates ("{name} scored {score}".format(...)) but requires matching placeholders to arguments, which gets harder to scan as the template grows.%-formatting: compact for one or two values, but easy to get positional order wrong and the%s/%dtype markers are an extra thing to keep in sync with the actual argument types.
Performance
F-strings are generally fastest because the expressions inside {} are compiled straight into bytecode at compile time, no template string is parsed at call time. %-formatting is close behind for simple cases since it is a single lightweight C-level operation. str.format() tends to be the slowest of the three because it parses the template string and does attribute/index lookups on every call. This ordering is well documented CPython behavior; treat it as a rule of thumb, not a promise for every input shape; measure with timeit on your actual code if a specific format call is on a hot path, rather than assuming the ranking transfers exactly.
Security: format-string injection
The risk is specifically about who controls the template string, not the values being substituted:
- With
%-formatting andstr.format(), if the template itself (not just the values) comes from untrusted input, the attacker controls what gets read.str.format()is the sharper edge here because its placeholder syntax can traverse attributes and indexes on any object you pass in: feeding the template"leaked={0.secret}"through.format(some_object)readssome_object.secret, an attribute that was never meant to be exposed by the calling code.%-formatting has no attribute/index traversal, so its blast radius is smaller but not zero (an attacker-controlled format spec can still cause exceptions or unexpected coercions). - F-strings do not have this specific injection shape, because the expression is fixed in the source code by whoever wrote the code, not supplied as a separate runtime string. The residual risk with f-strings is indirect: if you ever build an f-string-like template dynamically and
eval()it, you have reintroduced the exact same problem by hand. - The universal fix: never format a template that comes from outside your code's control, regardless of which of the three you use, and prefer structured, escaping-aware serializers (
json.dumps,logging's own%-style deferred formatting) for anything that touches untrusted data or gets machine-parsed downstream.
Worked example
class Config:
def __init__(self):
self.secret = "top-secret-value"
cfg = Config()
print("value=%s" % (cfg.secret,)) # value=top-secret-value
print("value={}".format(cfg.secret)) # value=top-secret-value (safe: fixed template)
untrusted_template = "leaked={0.secret}"
print(untrusted_template.format(cfg)) # leaked=top-secret-value (unsafe: template is data)
name = "world"
print(f"hello {name}") # hello world
The third line is the injection risk made concrete: if untrusted_template came from a config file, a URL parameter, or any place an attacker can reach, .format(cfg) reads cfg.secret even though nothing in the calling code explicitly asked for it.
Trade-offs & pitfalls
- Deferred formatting for logging. For log calls, prefer
logger.debug("loss=%.4f step=%d", loss, step)(parameterized,%-style) over an f-string built eagerly,logger.debug(f"loss={loss:.4f}"): the f-string always pays the formatting cost even when the debug level is disabled, while the parameterized call only formats if the message is actually going to be emitted. - Locale and precision control matter for
%andstr.format()numeric specs the same way they do for f-strings (:.4fworks identically in an f-string and instr.format()); this is a formatting-spec detail shared across all three, not a differentiator between them. - Common wrong turn: treating "f-strings are fastest and most readable" as "always safe to use directly on untrusted data." F-strings sidestep the template-injection shape described above, but they do nothing to sanitize the values you interpolate; a value containing control characters or extremely long content can still cause downstream problems (e.g. log injection, oversized output) regardless of which formatting style produced it.
You have a flaky unit test that intermittently times out. Outline a debugging and remediation plan in a Python project with pytest and CI, including how to reproduce locally, collect traces, and enforce test stability.
Sample Answer
Debug & remediation plan:
- Reproduce locally
- Run the test repeatedly: pytest -k name --maxfail=1 -q --count=100 (pytest-repeat plugin) to reproduce flakiness.
- Run under same env as CI (Python version, env vars).
- Collect traces
- Add logging with timestamps or use pytest -s; capture stack traces on timeout by increasing timeout to inspect where it hangs.
- Use faulthandler: in test setup call faulthandler.dump_traceback_later and dump on timeout.
- Use pytest --durations and --showlocals to find slow parts.
- Isolate cause
- Check for test order dependency: run single test and with -k to see isolation.
- Mock external resources (network, DB, filesystem) and use deterministic fixtures.
- Check for race conditions: add sleeps, use thread/async synchronization, run under race detectors or sanitizers if available.
- Fixes
- Replace real I/O with fixtures/mocks or use ephemeral resources.
- Make async tests use event loop proper awaits and timeouts; use pytest-timeout to fail faster.
- Ensure teardown always cleans up (use tmp_path, monkeypatch).
- Enforce stability in CI
- Add time budget and stricter assertions; mark inherently flaky tests with @pytest.mark.flaky only after triage.
- Run flaky-test detection in CI: rerun once and fail if consistently flaky.
- Add metrics and alerting if test durations increase.
Result: deterministic, fast tests and fewer CI interruptions.
Worked walkthrough on one concrete flaky test: say test_worker_processes_queue starts a background thread that pushes a result onto a queue.Queue, then the test does time.sleep(0.05) and asserts the queue has an item, assuming 50ms is always enough for the worker thread to finish. On a fast, quiet machine this passes almost every time; on a loaded CI runner the worker thread sometimes has not finished by the time the fixed sleep ends, and the assertion fails, only sometimes, which is exactly what "flaky" means.
- Reproduce locally:
pytest -k test_worker_processes_queue --count=100(--countcomes from thepytest-repeatplugin, which adds that option to run the same test that many times in one pytest invocation) runs the test repeatedly in one process; a genuinely flaky test now fails on some of those runs and passes on others, in the same environment, which rules out "it only fails in CI" as an explanation and confirms the flakiness is reproducible on demand rather than a one-off fluke. - Collect traces:
pytest --durations=0 --showlocals(--durations=0prints every test's wall-clock duration sorted slowest-first instead of only the slowest few;--showlocalsprints each local variable's value at the point of failure) shows, on a failing run, that the assertion failed with the queue still empty: the worker thread genuinely had not produced a result yet, not that the assertion logic itself was wrong.faulthandler.dump_traceback_later(a standard-library facility you arm with a countdown; if the process is still alive when the countdown expires, it dumps every thread's current stack trace, which is exactly what you need for a hang rather than a fast failure) would show the worker thread still inside its own processing function at the moment the assertion already gave up, confirming a genuine race between the fixed sleep and the thread's actual completion time, not a logic bug in the worker itself. - Isolate cause: running the test alone (not the full suite) still reproduces it sometimes, which rules out cross-test state leakage as the cause; the pattern (passes when the machine is quiet, fails more often when it is busy) is the signature of a race condition between a fixed-duration sleep and actual, variable-duration background work, not a logic bug.
- Fix: replace the fixed
time.sleep(0.05)with an actual wait on the real completion signal,queue.get(timeout=5), which blocks only as long as it actually needs to (returning the moment the worker pushes its result, and only failing the test if 5 real seconds pass with nothing produced), removing the race entirely instead of tuning the sleep duration and hoping it is long enough. - Enforce in CI: add
pytest-timeout(a plugin that fails any individual test that runs longer than a configured limit, rather than letting a hang stall the whole CI job indefinitely) as a backstop for any future test that hangs outright, and only reach for@pytest.mark.flaky(a marker that tells a plugin to automatically retry a failing test before reporting it failed) after a test like this has actually been triaged and understood, never as a first response to red CI, since it hides the exact race just diagnosed instead of fixing it.
A race detector or sanitizer (a class of tool, e.g. a language's -race flag or ThreadSanitizer, that instruments a program at build or run time to flag unsynchronized concurrent access to shared memory as it happens, rather than waiting for it to occasionally produce a wrong answer) is the equivalent tool one level down, in compiled languages with real memory-level data races. Python's GIL rules out that specific class of memory corruption, so the direct equivalent here is exactly the timing/synchronization walkthrough above, not a separate tool to reach for.
Design a caching decorator whose entries expire after a configurable TTL. What data structure backs the cache, how do you evict stale entries (lazily on access versus proactively), and what changes if you also need it to be thread-safe?
Sample Answer
Approach
Back the cache with a plain dict: keys are a canonical (args, sorted(kwargs.items())) tuple, values are (result, expires_at) pairs, and expires_at is measured with time.monotonic() rather than time.time() so the TTL is not affected by the system clock being adjusted (NTP sync, manual clock changes) mid-run. Eviction happens lazily: every access checks whether its own entry is stale and drops it if so, which means a key that is never looked up again just sits in memory until the janitor (below) or a fresh call for that key clears it. Adding thread-safety is a single threading.Lock guarding every read and write of the dict, since dict operations are not safe to interleave with a delete.
Code (Python 3.12)
import time
import threading
from functools import wraps
def ttl_cache(ttl_seconds):
'''Decorator factory: cache each call's result for ttl_seconds.
Thread-safe: one lock guards every read and write of the cache.
'''
def decorator(func):
store = {} # key -> (value, expires_at)
lock = threading.Lock()
@wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.monotonic()
with lock:
cached = store.get(key)
if cached is not None:
value, expires_at = cached
if expires_at > now:
return value
del store[key] # lazy eviction: stale, drop it
value = func(*args, **kwargs)
store[key] = (value, now + ttl_seconds)
return value
def cache_clear():
with lock:
store.clear()
wrapper.cache_clear = cache_clear
return wrapper
return decorator
calls = {"n": 0}
@ttl_cache(ttl_seconds=0.05)
def slow_square(x):
calls["n"] += 1
return x * x
print(slow_square(4)) # 16
print(slow_square(4)) # 16, served from cache
print(calls["n"]) # 1
time.sleep(0.06)
print(slow_square(4)) # 16, recomputed: previous entry had expired
print(calls["n"]) # 2
Key points
- A dict gives average O(1) lookup by (args, kwargs), which is the right structure whenever the key space is a finite, hashable set of call signatures; if an argument is unhashable (a
list, adict), building the key itself raisesTypeErrorbefore the cache is even consulted. - Lazy eviction (check-and-drop on access) costs nothing extra for keys that keep getting called, but a key that is called once and never again just occupies memory forever, since nothing ever revisits it to notice it went stale. A proactive sweep fixes that: a
daemon=Truebackground thread that wakes up on an interval, takes the lock, and removes every entry whoseexpires_athas passed, independent of whether anyone accesses it. The two are complementary, not either/or: lazy handles the common case cheaply, proactive bounds worst-case memory for cold keys. - Thread-safety changes exactly one thing structurally: every dict read-then-maybe-write sequence in
wrapperhas to happen atomically with respect to other threads, or two threads can both see a miss, both callfunc, and one result silently overwrites the other (wasted work, not corruption, since both results are equally valid, but still wrong for a function with side effects). A single lock around the whole "check, maybe compute, store" block is sufficient here because the cached function's own body is not being timed for lock duration; iffuncitself is slow, holding the lock across the call tofuncserializes unrelated cache misses on different keys too, which is the real cost of this simple approach: it trades throughput under contention for a small, easy-to-reason-about critical section. A production version would narrow the lock to just the dict operations and use a per-key lock (or anif key not in store: store[key] = SENTINELdouble-checked pattern) to let different keys compute concurrently.
Complexity
Per call: O(1) average for the dict lookup/insert (hashing the key), plus whatever func itself costs on a miss. Space: O(u) where u is the number of distinct, not-yet-expired argument combinations currently cached.
Edge cases
- Unhashable arguments raise
TypeErrorwhen the key tuple is built, beforefuncever runs; this is the same failure mode as any dict-keyed cache and is not TTL-specific. - A function with side effects (writes to a file, mutates a global) should generally not be cached at all, since a cache hit silently skips the side effect on the second call.
- Two related, absorbed variants of this same design:
- "Time the call and cache it": extend the stored tuple to
(result, expires_at, duration), timingfuncwithtime.perf_counter()around the call on a miss, so callers can inspect how expensive an entry was to produce (useful for deciding TTL length empirically) without changing the cache's core structure. - "Cache to disk via pickle": persisting
storeacross process restarts withpickle.dump/pickle.loadtrades in-memory-only simplicity for durability, at the cost of needing every cached value (and every key, since tuples of primitives pickle fine but arbitrary objects may not) to be picklable, plus a decision about whether a pickled entry'sexpires_atshould still be honored after the process was down for a while (it should: monotonic time does not survive a restart, so persisted entries need to be re-validated against wall-clock-derived expiry, or simply treated as expired on load).
- "Time the call and cache it": extend the stored tuple to
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.
What's the difference between repr and str on a Python class? Write a small class that implements both, showing the developer-facing versus user-facing representation.
Sample Answer
Direct answer
__repr__ returns the developer-facing representation: unambiguous, ideally something that could recreate the object, and shown in the REPL and inside containers. __str__ returns the user-facing representation: readable, meant for print() and str() calls. If a class defines only __repr__, Python falls back to it for str() too, so __repr__ is the one you should always implement; __str__ is the optional, friendlier layer on top.
Structured elaboration
Where each one gets called:
print(obj)andstr(obj)call__str__; if it is not defined, Python falls back to__repr__.repr(obj), the interactive REPL echoing a value, and displaying an object inside a container (a list, dict, or tuple) all call__repr__, always, even if__str__is defined. This is a common surprise:print([my_object])shows thereprofmy_object, not itsstr.- Both should be deterministic and side-effect free; neither should do expensive work like a network call or a database lookup.
Design guidance:
__repr__: aim for something likeClassName(field=value, ...)so a reader (or theeval()of the string, when feasible) can reconstruct the object's state.__str__: aim for a short, human sentence, useful in logs, dashboards, or error messages shown to non-developers.- If the two would be identical, just implement
__repr__and skip__str__entirely; do not duplicate the same string in both methods.
Worked example
class Record:
def __init__(self, record_id, features):
self.record_id = record_id
self.features = tuple(features)
def __repr__(self):
return f"Record(record_id={self.record_id!r}, features={self.features!r})"
def __str__(self):
return f"Record {self.record_id}: {len(self.features)} features"
r = Record(42, [0.1, 0.2, 0.3])
print(repr(r)) # Record(record_id=42, features=(0.1, 0.2, 0.3))
print(str(r)) # Record 42: 3 features
print(r) # Record 42: 3 features (print uses __str__)
print([r]) # [Record(record_id=42, features=(0.1, 0.2, 0.3))] (container display uses __repr__)
The last line is the detail people miss: even though Record defines a friendly __str__, wrapping it in a list and printing shows the __repr__ form, because container repr always renders its elements with their repr.
Trade-offs & pitfalls
- Skipping
__repr__and only writing__str__is the wrong way around: without__repr__, debugging in a REPL or reading a log traceback shows Python's default<Record object at 0x7f...>, which tells you nothing about the object's state. - Do not put secrets or large blobs in
__repr__; because it is used in error messages, tracebacks, and log lines, an unfiltered__repr__on an object holding an API key or a full feature vector for a million-row dataset can leak sensitive data into logs or make debugging output unreadable. Truncate or redact. - For a
dataclass,@dataclassgenerates a reasonable__repr__automatically (field=value for every field) but no__str__; you still write__str__yourself if you want a friendlier display form. - Keep
__repr__deterministic (no unordered dict/set field showing in a different order per call) so log diffs and test assertions are stable.
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.